Azure SQL Managed Instance vs Azure SQL Database: How I Actually Made the Call
DEV Community

Azure SQL Managed Instance vs Azure SQL Database: How I Actually Made the Call

The starting point: what we were actually migrating

Context matters more than features, so here's ours:

  • One production SQL Server database, roughly 200 GB in size, that had lived on-prem for years.
  • Three separate backend services - two application backends and one integration API - all reading and writing to that same database.
  • More than 25 SQL Server Agent jobs running scheduled work against the database: batch processing, data movement, housekeeping.
  • A hard requirement that the database must never be exposed to the public internet - access only over a private network on port 1433.
  • A small team, a compressed timeline (we went from zero Azure resources to production in weeks, not months), and a real budget owner asking why every euro was being spent.

That last point is important. This wasn't a greenfield app designed cloud-native. This was a lift-and-shift of a living, heavily-scheduled, single-database system that three services depend on simultaneously.

The fork: two genuinely good options

Azure SQL Database is the "born in the cloud" option - a database-scoped service, fast to provision, cheap to start, with elegant HA options like zone-redundant compute and active geo-replication. I took it seriously. In fact, during the evaluation I fully sketched out what a production + DR architecture on Azure SQL Database would look like for us: a General Purpose single database at 16 vCores with zone-redundant compute in the primary region, an asynchronous geo-replica standing by in a secondary region, 7-day PITR for operational recovery, and long-term retention for compliance. On paper it was a beautiful design.

SQL Managed Instance is the "SQL Server, but managed" option - an instance-scoped service deployed inside your own virtual network, with near-100% surface-area compatibility with on-prem SQL Server. The honest question I forced myself to answer: does our workload actually need the instance, or am I just choosing the familiar thing?

During architecture review, this exact challenge came back to me formally: "Re-validate whether SQL Managed Instance is the intended PROD choice." Someone has to defend the decision with evidence, and that someone was me. Here is the defence, factor by factor.

Deal-breaker #1: the .bak restore

The migration path itself decided a lot. Our database was ~200 GB of production data. The fastest, safest, most verifiable way to move a SQL Server database of that size is a native .bak backup restored directly into the target - full fidelity, all objects, all settings, one operation. Only SQL MI supports native .bak restore (RESTORE DATABASE ... FROM URL, pointing at a backup uploaded to blob storage). Azure SQL Database does not accept .bak files at all - your options there are BACPAC import (a logical export/import that is painfully slow and fragile at 200 GB scale, and drops things a logical export can't carry) or transactional replication / data migration tooling, which adds days of setup and validation for a one-time move.

For us this alone nearly closed the argument. When your cutover window is a weekend and your database is 200 GB, "restore the .bak, run DBCC, point the connection strings" is a plan you can rehearse and trust. In our execution plan, the restore was literally a single day on the critical path: instance provisioned on day two, .bak restored on day three, app-to-database connectivity validated on day five.

Lesson: the migration mechanism is a first-class selection criterion, not an afterthought. A platform you can't get your data into cleanly is not a candidate.

Deal-breaker #2: 25+ SQL Agent jobs

This is the one that people underestimate the most. Our database had more than 25 SQL Server Agent jobs. Azure SQL Database has no SQL Server Agent. Every one of those jobs would need to be re-platformed - rewritten as Elastic Jobs, Azure Functions, Logic Apps, or pipeline schedules - then individually re-tested, re-monitored, and re-documented. That's not a migration task; that's a re-engineering project hiding inside a migration. Twenty-five-plus jobs means twenty-five-plus opportunities for a schedule to silently not fire in production, for a permission model to differ, for an ops runbook to go stale.

On our timeline, with our team size, that risk was simply not sellable. SQL MI ships with SQL Agent intact. Our jobs restored with the database in the same .bak, on the same schedules, with the same T-SQL. Zero rewrites. Zero behavioural drift.

Lesson: count your Agent jobs before you count vCores. If the number is more than a handful, SQL Database's per-job re-engineering cost usually dwarfs any infrastructure saving.

Factor #3: one instance, many databases

Azure SQL Database is priced and scoped per database (elastic pools soften this, but the model is still database-centric). SQL MI is priced per instance - and an instance hosts many databases behind one compute allocation, one security boundary, one endpoint.

Our situation: one database today, but a realistic pipeline of up to six more internal application databases that could consolidate onto the same instance later. On MI, each new database is a restore - no new resource, no new networking, no new monitoring plumbing, no new line on the invoice. Cross-database queries, shared logins, and instance-level configuration all behave exactly as the applications and DBAs expect from on-prem.

We provisioned the production instance as General Purpose, premium-series hardware, 6 vCores, 256 GB storage - deliberately sized so the instance is the platform, not just a container for one database.

Lesson: if your organisation thinks in terms of "a SQL Server that hosts our databases" rather than "a database per app," MI's instance model matches your operating reality and your chargeback model.

Factor #4: Resource Governor - the underrated differentiator

This one I didn't fully appreciate at selection time; it became decisive later, which retroactively validated the choice. Remember: three services share one database. Two are user-facing applications; the third is an integration layer that receives bursty machine-to-machine traffic through an API gateway. Once everything was live and we started planning load tests, the obvious question surfaced: what stops an integration burst - say, 100,000 calls in a ramp - from starving the user-facing apps at the database layer? On Azure SQL Database, the honest answer inside a single database is: not much. You can throttle upstream, tune queries, or physically split the workload into separate databases (which our schema and cross-service data model made unattractive).

On SQL MI, the answer is Resource Governor - fully supported on Managed Instance, unavailable on Azure SQL Database. We gave the integration service its own SQL login, then:

-- Hard-capped pool for the integration workload
CREATE RESOURCE POOL IntegrationPool WITH (
    MAX_CPU_PERCENT = 30,
    CAP_CPU_PERCENT = 30,  -- hard cap even when the instance is idle
    MAX_MEMORY_PERCENT = 30
);

CREATE WORKLOAD GROUP IntegrationGroup WITH (
    IMPORTANCE = LOW,
    REQUEST_MAX_MEMORY_GRANT_PERCENT = 25,
    MAX_DOP = 2
) USING IntegrationPool;

-- Classifier: route sessions by login
CREATE FUNCTION dbo.fn_WorkloadClassifier() RETURNS SYSNAME
WITH SCHEMABINDING AS
BEGIN
    RETURN (
        CASE WHEN SUSER_SNAME() = 'integration_service_user'
             THEN 'IntegrationGroup'
             ELSE 'default'
        END
    );
END;

ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = dbo.fn_WorkloadClassifier);
ALTER RESOURCE GOVERNOR RECONFIGURE;

Now the integration layer can never take more than ~30% of instance CPU and memory, and under contention the user-facing queries win the scheduler. We designed this as one layer of a three-layer isolation strategy: rate-limiting policies at the API gateway (reject excess load cheaply at the edge), Resource Governor at the instance (contain whatever gets through), and - in the next phase - read offload to a readable geo-replica using ApplicationIntent=ReadOnly. The Resource Governor caps are being validated as part of the load-test ramp itself.

Two honest caveats from the trenches: on the General Purpose tier, IO comes from remote storage and Resource Governor doesn't fully cap IO pressure - a badly written scan-heavy query can still hurt, so pair the caps with MAX_DOP limits and index tuning. And keep the classifier function trivially simple, because it executes on every login.

Lesson: if multiple workloads share one database and you can't split them, Resource Governor is a platform-level answer that only MI gives you. That capability alone can justify the instance.

Factor #5: network posture - MI is private by construction

Our security bar was explicit: no public path to the database, ever. SQL MI deploys inside your VNet, into a dedicated, delegated subnet, with a private IP address. In our build, an NSG restricts inbound database traffic to port 1433 from the app-integration subnet only; the app services reach it over VNet integration; the deployment pipeline reaches it through a self-hosted agent VM inside the same network.

When the security review later asked us to "configure private endpoint / disable public access / restrict to VNet," the answer for the database line items was simply: it is already like that - it was built that way. Azure SQL Database can absolutely be made private (private endpoints, deny public network access), but private is something you add to it. On MI, private is the starting state. For a compliance-sensitive workload, starting private removed a whole class of review findings before they existed.

One hard-won caution: verify, don't assume. We made it a standing gap-register item to confirm that the instance's optional public data endpoint stays disabled, and we learned separately that ARM template exports do not reliably surface security state (we caught a Transparent Data Encryption discrepancy only by querying sys.dm_database_encryption_keys directly). Trust the engine's DMVs over exported templates.

What we did NOT give up by choosing MI

The fear with MI is that you're choosing the "legacy-friendly" option and losing PaaS goodness. In practice we kept everything that mattered:

  • Automated, zero-admin backups: weekly fulls, differentials every 12 hours, transaction log backups every 5-10 minutes - no agent jobs, no scripts, no tapes. Effective RPO for point-in-time restore is minutes.
  • Point-in-Time Restore (PITR) with a 7-day window at ~5-minute granularity for operational recovery (bad deployment, accidental delete).
  • Geo-redundant backup storage, plus long-term retention policies for compliance history.
  • Built-in local HA on the General Purpose tier: compute is stateless - if the node fails, Azure spins up a new one and reattaches the remote storage automatically.
  • Patching, version servicing, TLS enforcement (1.2 minimum) - all platform-managed.
  • Pay-as-you-go licensing with no upfront license commitment.

So the real comparison was never "PaaS vs not-PaaS." Both options are PaaS. The comparison was database-scoped PaaS vs instance-scoped PaaS, and our workload is instance-shaped.

The trade-offs I accepted - with eyes open

No honest decision write-up skips this part. Choosing MI cost us three things, and each went into the risk register explicitly rather than being hand-waved:

  1. Provisioning lead time. An MI takes hours to provision (ours was a 4-6 hour operation) versus minutes for Azure SQL Database. This made the instance the critical path of the entire migration plan - if the MI slipped, everything shifted right. We managed it by kicking off instance creation first thing on day two and running everything else in parallel. It also means MI-based DR-by-geo-restore has a long realistic RTO, because a replacement instance also takes hours to provision - which leads to…

  2. DR posture is a phased decision, not a default. Our initial deployment is single-zone, no failover group. We documented this as an explicit, accepted risk with a named decision owner: regional DR today means geo-restoring into a freshly provisioned instance (RTO measured in hours). The approved next-phase path is a compute upgrade to 16 vCores with zone-redundant compute plus a geo-replica in a secondary region - which also becomes the read-offload target in the isolation strategy above. On Azure SQL Database that HA/DR elegance is cheaper and faster to switch on; on MI it's a bigger, more deliberate investment. We chose to phase it rather than pay for it on day one.

  3. General Purpose IO characteristics. GP-tier MI uses remote storage; our instance runs at a defined IOPS envelope, and IOPS scale with file size. Resource Governor caps CPU and memory but not IO pressure from unoptimized queries. Mitigation: query tuning on the hot paths, DOP limits, an IO alert with dynamic thresholds, and a storage-capacity alert at 80% - plus load testing before we declare victory.

The decision, compressed

If I strip everything down to the scorecard I effectively used:

Criterion Azure SQL Database SQL Managed Instance Weight for us
Native .bak restore of a ~200 GB DB βœ— (BACPAC/DMS only) βœ“ single restore operation Deal-breaker
25+ SQL Agent jobs βœ— (no SQL Agent) βœ“ fully compatible Deal-breaker
Instance-scoped multi-database management Limited (database-centric) βœ“ native multi-DB High
Resource Governor for workload isolation βœ— βœ“ fully supported High (validated post-facto)
Private-by-default VNet deployment Add-on (private endpoints) βœ“ built-in High
Provisioning time Minutes Hours Medium (managed via planning)
Out-of-box HA/DR elegance (geo-replication, zone-redundant compute) βœ“ cheaper, faster Phased, larger investment Medium (deliberate trade-off)
IO elasticity on General Purpose tier Same remote storage Same remote storage (IOPS scales with file size) Medium (mitigated with tuning)

The decision was clear: for our instance-shaped workload, SQL Managed Instance was the right call.

Comments

No comments yet. Start the discussion.