Optimizing Multi-Tenant Costs with Azure SQL Elastic Pools
Elastic pools let many tenant databases share one block of Azure SQL capacity. How the sharing works, a CLI configuration to copy, and the limits that bite in production.
31 Aug 2025, 03:01 UTC

Provisioning one Azure SQL database per tenant is simple until the bill arrives: each standalone database is sized for its own peak, and SaaS tenants rarely peak together. The useful answer is to put tenant databases into an elastic pool, where they share one fixed block of compute and storage. When tenant load is bursty and uncorrelated, a pool sized to the combined average instead of the sum of the peaks typically costs less than standalone databases at the same service level.
How the sharing actually works
In a pool you pay for pool capacity per hour, not per database. Each tenant database gets a guaranteed minimum and a per-database maximum, both drawn from the pool's total. An idle tenant's unused capacity becomes available to its neighbors, which is what makes consolidation economical: the pool absorbs the randomness instead of you paying for every tenant's worst case.
Two consequences follow from this model. First, compute is finite: if enough tenants hit their maximums at once, everything beyond the pool's total is throttled, so pool-level utilization is the number to watch. Second, storage is shared as well, so one runaway tenant can fill the pool and block writes for everyone unless per-database size is capped.
A worked configuration
The following Azure CLI commands create an 8-vCore General Purpose pool and place a tenant database in it. Run them from any machine with the Azure CLI installed and authenticated with az login; you need permission to modify resources on the logical server, such as Contributor on the resource group. Replace the resource group, server, and pool names with your own values.
az sql elastic-pool create \ -g my-resource-group \ -s my-server \ -n my-pool \ --edition GeneralPurpose \ --capacity 8 \ --db-max-capacity 4 \ --db-min-capacity 0.25 az sql db create \ -g my-resource-group \ -s my-server \ -n tenant-alpha \ --elastic-pool my-pool
The three sizing parameters do the real work:
--capacity 8sets the total vCores shared by every database in the pool. This is what you pay for, per hour.--db-max-capacity 4prevents any single tenant from consuming more than half the pool, which contains noisy neighbors.--db-min-capacity 0.25guarantees each database a quarter vCore even under full pool contention, so one tenant's busy day cannot starve another to zero.
To confirm the pool exists with the intended limits, run az sql elastic-pool show -g my-resource-group -s my-server -n my-pool and check that the SKU capacity and the per-database minimum and maximum match what you requested.
Moving existing tenants in, and back out
Standalone databases move into a pool without an export or import; the service updates the database's placement and the move usually completes in seconds, though in-flight connections can be interrupted. The database bills as part of the pool from that point on.
az sql db update \ -g my-resource-group \ -s my-server \ -n tenant-beta \ --elastic-pool my-pool
The move is reversible: rerun the same command without --elastic-pool, passing an explicit --edition, --capacity, or --service-objective to return the database to standalone compute. Because both directions change billing and capacity, migrate one tenant first, verify application behavior, then move the rest.
Limits and common mistakes
One tenant filling shared storage
Pool storage is a single shared quota. A bulk import in one tenant can exhaust it, after which every database in the pool starts failing writes. Set an explicit per-database maximum size (MAXSIZE) on tenant databases and alert on the pool's allocated_data_storage_percent metric well before it reaches 100%.
Sizing from peaks instead of averages
The economics only hold if the pool is sized to combined average load. As a rule of thumb, a pool is usually cheaper when average utilization across tenants is well under a third of the sum of their peaks. If tenants correlate — shared business hours, synchronized batch jobs — the smoothing effect shrinks, and a pool can throttle under load that standalone databases would have absorbed.
Assuming settings apply pool-wide
Several things engineers expect to configure once are actually per database or otherwise restricted:
- Serverless compute is not available inside elastic pools; only provisioned General Purpose or Business Critical. A tenant that needs serverless stays outside the pool.
- TDE, auditing, and threat detection are configured per database. Applying them to a template database does not cover new tenants, so build them into the provisioning path.
- Point-in-time restore works per database, producing a new database that can join the same pool. There is no whole-pool restore, and geo-replication for disaster recovery is set up per database, not per pool.
- Cross-database queries through elastic query are not transactionally consistent; keep them out of OLTP paths.
- Changing the pool's edition, for example General Purpose to Business Critical, applies to every database at once and can cause brief connectivity blips.
- Database count is limited per pool SKU, up to 500 for larger sizes, and per-database minimums multiply: 500 databases at a 0.25 vCore minimum already require 125 vCores of pool capacity. Limits change between service generations, so confirm the current limits table for your chosen capacity, and plan for a pool size you can actually monitor and restore database by database.
Watching the pool in production
Pool utilization is visible in Azure Monitor metrics such as cpu_percent, storage_percent, and allocated_data_storage_percent, and in the sys.elastic_pool_resource_stats view in the master database on the same logical server:
-- Run in the master database of the logical server
SELECT TOP 10
end_time,
avg_cpu_percent,
avg_data_io_percent,
storage_percent
FROM sys.elastic_pool_resource_stats
WHERE pool_name = 'my-pool'
ORDER BY end_time DESC;
A practical starting alert: pool cpu_percent sustained above 80% for 15 minutes means the pool, not one tenant, is the bottleneck. The fix is scaling pool capacity or moving a heavy tenant out, not tuning a single database.
How to check the result
- Confirm pool settings with
az sql elastic-pool show: capacity, edition, and per-database minimum and maximum. - Load-test one tenant with a tool such as
ostresswhile watching pool CPU and that database's individual CPU in Azure Monitor. The per-database maximum should cap the tenant while other tenants' metrics stay flat. - After a day, check Azure Cost Management filtered to the
Microsoft.Sql/servers/elasticPoolsresource type to confirm billing has shifted from per-database compute to the pool SKU.
The decision in one line: elastic pools pay off when you have many tenant databases with bursty, uncorrelated load and can live with shared limits; they work against you when tenants correlate, a tenant needs serverless or hard isolation, or storage growth is unpredictable. Start with one or two tenants in a test pool, verify isolation behavior under load, and let measured utilization rather than peak provisioning set the pool size.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.