FinOps Cost Optimization


flowchart LR
    subgraph Inform["1. Inform"]
        IPAD[" "]
        I1[Visibility]
        I2[Allocation]
        I3[Benchmarking]
        I4[Budgeting]
        IPAD ~~~ I1
    end
    subgraph Optimize["2. Optimize"]
        OPAD[" "]
        O1[Rate optimization]
        O2[Usage optimization]
        O3[Architecture changes]
        OPAD ~~~ O1
    end
    subgraph Operate["3. Operate"]
        PPAD[" "]
        P1[Policy]
        P2[Automation]
        P3[Continuous iteration]
        PPAD ~~~ P1
    end
    Inform --> Optimize --> Operate --> Inform
    style IPAD fill:transparent,stroke:transparent,color:transparent
    style OPAD fill:transparent,stroke:transparent,color:transparent
    style PPAD fill:transparent,stroke:transparent,color:transparent

FinOps Fundamentals For SQL Server

Before auditing anything, it is worth being precise about what FinOps actually is, why it exists as a separate discipline from cost accounting or capacity planning, and how it applies to a stateful workload like SQL Server. FinOps is defined by the FinOps Foundation as the operating model for managing variable cloud spend through collaboration between engineering, finance, and business teams. It is a loop, not a project — the same workload is re-examined on a cadence because both the workload and the pricing it runs against keep moving.

The Three FinOps Phases

FinOps organizes work into three phases that repeat continuously over the life of a workload. They are not a one-time sequence; every workload is somewhere on the loop at any given time.

Phase 1 — Inform

Before any optimization decision. Inform is the phase that earns the right to act on the other two phases. It is typically triggered by new workload, new quarter, budget review, cost anomaly alert, or architectural change that invalidates prior cost assumptions. Inform is about visibility — who owns which resources, how much they cost, what they are compared to (benchmark), and what the budget says they should cost. It runs wherever the bill lives (GCP Billing, BigQuery billing export, cost dashboards). Give every stakeholder the same picture of current spend, attribution, and variance against budget, so optimization is a discussion about shared facts rather than anecdotes.

For SQL Server specifically, Inform means answering questions such as: how much persistent disk does this instance allocate, how much of that is actually used, what is the daily snapshot storage footprint, what does the full backup chain cost per month in GCS, what is the committed use discount coverage for this VM. The first H2 section of this note is essentially the Inform phase for a single SQL Server instance.

Phase 2 — Optimize

After Inform has produced a credible baseline and named the biggest line items. It is typically triggered by A cost signal in the Inform baseline that exceeds the budget, exceeds the benchmark for that workload class, or does not match the business value the workload delivers. Optimize is where the engineering actions happen. It splits cleanly into three lever categories — rate optimization (buying cheaper units), usage optimization (consuming fewer units), and architecture changes (changing what the workload does). Reduce spend, improve price-performance, or free budget for higher-value work, without sacrificing the reliability and latency targets the business actually needs.

For SQL Server on GCP the three lever categories map to:

  • Rate optimization — Committed Use Discounts on the VM, Sustained Use Discounts (auto-applied), GCS storage-class transitions via lifecycle rules, BYOL vs. license-included image choice.
  • Usage optimization — Backup compression, data compression, log-file right-sizing, snapshot retention trimming, right-sizing the VM to observed CPU and memory footprints, switching non-production instances to stop-when-idle schedules.
  • Architecture changes — Moving cold partitions to BigQuery or GCS, splitting read-only workloads to secondary replicas on cheaper VMs, replacing a large always-on VM with a cluster of smaller ones on spot capacity for rebuildable tiers.

Phase 3 — Operate

Continuously, after the first Optimize pass has landed. It is typically triggered by drift. A cost that was optimized tends to regress over time as engineers add workloads, forget to re-tag resources, let log files grow, or skip lifecycle-policy updates. Operate is policy and automation — the guardrails that keep optimized state from decaying. It runs in GCP Organization Policy, budget alerts, lifecycle rules, tagging policies, and scheduled jobs. Lock in the gains from Optimize, detect regressions within hours or days rather than months, and shift future decisions toward the cheaper path by default.

For SQL Server, Operate looks like: GCS lifecycle rules that force cold backups off Standard class, Cloud Scheduler jobs that stop non-prod VMs overnight, budget alerts tied to project labels, Recommender policies that flag oversized machine types, and scheduled queries against msdb.dbo.backupset that alert when the compression ratio drops unexpectedly.

The Six FinOps Principles

The FinOps Foundation publishes six principles that orient decisions inside the three phases. They matter because every non-trivial cost question eventually pits two of them against each other.

PrinciplePractical meaning for SQL Server
Teams need to collaborateDBAs, platform, finance, and application owners look at the same dashboard, not separate ones
Everyone takes ownership of their cloud usageEvery SQL Server instance has a labelled owner and a budget line
A centralized team drives FinOpsOne team owns the billing export, labels taxonomy, and Recommender policies — not each squad
Reports should be accessible and timelyCost data flows into BigQuery daily; dashboards query it, not monthly exports
Decisions are driven by business value of cloudRight-sizing is evaluated against SLO impact, not in isolation
Take advantage of the variable cost modelUse CUDs, spot VMs, preemptible workers, lifecycle transitions — static thinking leaves money on the table

Cost Vs. Value: Why SQL Server Differs From Stateless Services

A stateless web service can be right-sized based purely on CPU and request latency. A stateful SQL Server cannot — the cost of getting right-sizing wrong is asymmetric, because restoring from a wrong VM-shape decision is cheap, but restoring from a wrong storage or recovery-model decision can be catastrophic.

This asymmetry shapes every FinOps decision in the rest of this note:

  • Storage errors are hard to reverse. Shrinking a data file is slow and fragmenting. Disks cannot shrink in place on GCP. Retention that you under-provisioned cannot be recovered after the snapshot window has passed.
  • Compute errors are cheap to reverse. VM shape changes are a stop/start operation on GCE and take minutes. Committing to a 3-year CUD is costly to reverse, but the VM itself is elastic.
  • Licensing errors compound. Edition choice (Standard vs. Enterprise) affects every VM running SQL Server at that tier, not just one. Dropping from Enterprise to Standard requires removing all Enterprise-only features first, which means a multi-week project on any non-trivial estate.
  • Recovery objective errors are silent until they fire. Under-provisioning backup retention or snapshot frequency produces no daily symptom until a restore is needed, at which point the cost savings have already been spent and the recovery fails.

The practical implication: prioritize reversible cost levers first. Backup compression, data compression, lifecycle rules, VM right-sizing, and CUD coverage are all reversible or low-risk. Retention policy, recovery model, and licensing edition are last because their error cost is highest.

Roles: DBA, Platform, Finance, Engineering

FinOps is explicitly a cross-functional practice. For SQL Server on GCP the usual role split is:

RolePrimary FinOps responsibility
DBAOwns in-database cost signals: allocation, log sizing, backup compression ratios, data compression coverage, retention, recovery model
Platform / SREOwns infrastructure cost signals: disk type, snapshot policy, lifecycle rules, VM shape, CUD coverage, labelling
FinanceOwns budget, variance analysis, chargeback rules, CUD procurement
Application ownerOwns SLO and value context — how much downtime or latency is acceptable for the workload
FinOps leadOwns the dashboard, runs the cost review cadence, and arbitrates trade-offs between the roles above

None of these roles can optimize SQL Server spend alone. A DBA who enables page compression without knowing the VM is already CPU-constrained saves storage but moves the bottleneck. A platform engineer who cuts snapshot retention without knowing the recovery objective creates a silent recovery hole. Cost decisions in this note are all annotated with which role is expected to make them.


Current SQL Server Cost Signals

Start with the SQL Server facts that most directly influence cost. You do not need a cloud bill to see the first layer of waste — SQL Server already tells you a lot. Every query in this section runs live against the local stoxx instance and returns the actual current state of this environment. Treat the outputs as the starting baseline for the Inform phase.


flowchart TD
    A[SQL Server instance] --> B[Data file allocation]
    A --> C[Log file allocation]
    A --> D[TempDB footprint]
    A --> E[Backup history]
    A --> F[Compression state]
    B --> G[Persistent disk $]
    C --> G
    D --> G
    E --> H[GCS $]
    E --> I[Snapshot $]
    F --> G
    F --> H
    G --> J[Monthly bill]
    H --> J
    I --> J
    style A fill:#292e42,stroke:#565f89,color:#c0caf5
    style J fill:#1f3b2d,stroke:#73d13d,color:#c0caf5

Storage Footprint

Storage footprint is the first place cost waste hides, because allocated SQL Server space eventually becomes disk cost, snapshot size, backup size, or all three. The queries below move from the database level (what each database owns) down to individual files (where inside the database the bytes live) and usage (how much of the allocated space is actually filled).

Audit database data and log allocation across the instance

Start of any FinOps review, quarterly cost audit, or before right-sizing persistent disk on a GCE VM. It is typically triggered by new instance under FinOps management, unexplained disk cost growth, or preparation for a VM migration. Runs in any T-SQL session with VIEW SERVER STATE. Read-only. Single round trip against sys.master_files joined to sys.databases. No restart or downtime implication. Produce the instance-level allocation summary that identifies which databases actually own storage cost and which are noise. Every cost discussion that follows references this baseline.

FieldSource columnUnit / typeMeaning
database_namesys.databases.namesysnameDatabase logical name
recovery_model_descsys.databases.recovery_model_descnvarchar(60)FULL, BULK_LOGGED, or SIMPLE; determines whether log backups are required
data_size_mbsum(sys.master_files.size) where type=0decimal MBAllocated ROWS (data) space in MB. size is in 8 KB pages, converted via size * 8 / 1024
log_size_mbsum(sys.master_files.size) where type=1decimal MBAllocated LOG space in MB
total_size_mbsum(sys.master_files.size)decimal MBCombined allocated footprint — drives every downstream storage cost on this instance

This query returns the current data and log allocation for every database on the instance, with the recovery model tagged for context.

SELECT
    d.name AS database_name,
    d.recovery_model_desc,
    CAST(SUM(CASE WHEN mf.type = 0 THEN mf.size END) * 8.0 / 1024 AS decimal(12,2)) AS data_size_mb,
    CAST(SUM(CASE WHEN mf.type = 1 THEN mf.size END) * 8.0 / 1024 AS decimal(12,2)) AS log_size_mb,
    CAST(SUM(mf.size) * 8.0 / 1024 AS decimal(12,2)) AS total_size_mb
FROM sys.databases AS d
JOIN sys.master_files AS mf
    ON mf.database_id = d.database_id
GROUP BY d.name, d.recovery_model_desc
ORDER BY total_size_mb DESC;
database_namerecovery_model_descdata_size_mblog_size_mbtotal_size_mb
stoxxFULL712.001032.001744.00
stoxx_backupFULL712.001032.001744.00
stoxx_dbFULL768.00256.001024.00
tempdbSIMPLE64.008.0072.00
msdbSIMPLE15.311.2516.56
codex_tde_demoFULL8.008.0016.00
modelFULL8.008.0016.00
masterSIMPLE4.692.006.69

The main cost signal is stoxx plus its disposable copy stoxx_backup: together they own nearly 3.5 GB of allocated space, and in both cases the log allocation exceeds the data allocation. That ratio is unusual — it means log growth, log backup cadence, and snapshot scope matter materially for this environment. stoxx_db is the older sibling copy and adds another 1 GB. The system databases are small enough that almost all storage-cost discussion should focus on the three user databases, not on tempdb, msdb, model, or master. Three databases in FULL recovery is the biggest structural cost driver here — every one of them requires a log-backup chain on GCS.

ColumnValueWatchMeaningImplication
recovery_model_descFULLWatchFull log chain requiredDrives GCS log-backup storage cost and snapshot retention coordination
recovery_model_descSIMPLE✅ for non-criticalNo log backup neededLowest log-side cost; accepts only full/differential restore granularity
recovery_model_descBULK_LOGGEDWatchMinimally logged operationsReduces log growth during bulk loads; still needs log backups
log_size_mb> data_size_mbWatchLog allocation exceeds data allocationUsually signals missed or immature log-backup cadence
total_size_mb> 1 GB per DBWatchDatabase is a material storage cost contributorFocus right-sizing and compression effort here first

Audit file-level allocation and growth setting for stoxx

After the instance-level audit has pointed at a specific database; before changing file layout, enabling auto-growth, or moving files. It is typically triggered by A database with unbalanced log/data ratio, a rebuild plan, or preparation for a persistent disk re-layout on GCE. Runs as a read-only T-SQL query against sys.master_files. Requires VIEW SERVER STATE in SQL Server 2019 and earlier, VIEW SERVER PERFORMANCE STATE in SQL Server 2022+. Inspect how many physical files a database has, where each one lives, how big it is, and how it grows — the level of detail needed to reason about file-placement decisions and persistent disk layout.

FieldSource columnUnit / typeMeaning
file_idsys.master_files.file_idintFile identifier within the database. Primary data file is always 1
logical_namesys.master_files.namesysnameLogical name used in T-SQL ALTER DATABASE ... MODIFY FILE
type_descsys.master_files.type_descnvarchar(60)ROWS, LOG, FILESTREAM, or FULLTEXT
physical_namesys.master_files.physical_namenvarchar(260)OS-level file path
size_mbsys.master_files.size * 8 / 1024decimal MBCurrent allocated size. size is in 8-KB pages
max_size_mbsys.master_files.max_size * 8 / 1024decimal MBMaximum allowed size. -1 = grow until disk full (NULL here); 0 = no growth allowed; log cap is 2 TB (268435456 pages)
growth_settingsys.master_files.growth + is_percent_growthvarcharEither N MB or N %. When is_percent_growth = 1, growth is a percentage; when 0, it is in 8-KB pages
state_descsys.master_files.state_descnvarchar(60)ONLINE, RESTORING, RECOVERING, OFFLINE, etc.

This query returns every file belonging to the stoxx database with its physical path, current size, maximum allowed size, and growth configuration.

SELECT
    mf.file_id,
    mf.name AS logical_name,
    mf.type_desc,
    mf.physical_name,
    CAST(mf.size * 8.0 / 1024 AS decimal(12,2)) AS size_mb,
    CAST(CASE WHEN mf.max_size = -1 THEN NULL
              WHEN mf.max_size = 0  THEN 0
              ELSE mf.max_size * 8.0 / 1024 END AS decimal(12,2)) AS max_size_mb,
    CASE WHEN mf.is_percent_growth = 1 THEN CONCAT(mf.growth, ' %')
         ELSE CONCAT(CAST(mf.growth * 8.0 / 1024 AS decimal(12,2)), ' MB') END AS growth_setting,
    mf.state_desc
FROM sys.master_files AS mf
WHERE mf.database_id = DB_ID('stoxx')
ORDER BY mf.type, mf.file_id;
file_idlogical_nametype_descphysical_namesize_mbmax_size_mbgrowth_settingstate_desc
1stoxxROWS/var/opt/mssql/data/stoxx.mdf712.00NULL64.00 MBONLINE
2stoxx_logLOG/var/opt/mssql/data/stoxx_log.ldf1032.002097152.0064.00 MBONLINE

stoxx has the simplest possible layout — one ROWS file and one LOG file — which keeps reasoning about cost clean. Two things stand out: the log file has a 2 TB cap (max_size_mb = 2097152.00) which is the SQL Server default for LOG files and is almost certainly wrong for a 712 MB database, and both files grow in 64 MB fixed chunks, which is a sensible default that avoids percent-growth’s VLF-fragmentation problem. Cost-wise, the log file’s 1 GB current allocation on persistent disk at ~0.10/month, negligible in isolation, but in an estate of 200 databases the same pattern becomes $20/month of pure log allocation, and the snapshot blast radius scales with it.

ColumnValueWatchMeaningImplication
growth_settingN MB fixedFixed-chunk auto-growthPredictable VLF layout, stable log performance
growth_settingN %Percent-based auto-growthCreates progressively larger VLFs, fragments the log file, considered anti-pattern since SQL Server 2005
max_size_mbNULL (-1)WatchUnbounded growthAcceptable for data files with disk monitoring; dangerous for log files without log backups
max_size_mb0No growth allowedWill throw error 9002 / 1105 when exhausted; only use for intentionally fixed-size files
max_size_mb2 TB (LOG default)WatchSQL Server LOG default capRarely the right number — size it to expected log backup cadence instead
state_descOFFLINE / SUSPECTFile is not usableImmediate operational issue; cost is irrelevant until restored

Audit current log size and usage for stoxx

After file-level audit has identified an oversized or undersized log file; during log-growth investigation; before changing log-backup cadence. It is typically triggered by log file occupies more disk than data file, log space since last backup is rising, unexplained log growth events in the error log. Runs as a read-only T-SQL query against sys.dm_db_log_space_usage. The DMV is database-scoped — it returns a single row combining all log files of the current database. Must be run inside the stoxx database context (not from master). Requires VIEW SERVER STATE (2019-) or VIEW SERVER PERFORMANCE STATE (2022+). Quantify exactly how much of the allocated log space is actually in use right now and how much has accumulated since the last log backup, so log-sizing and backup-cadence decisions are driven by numbers rather than guesswork.

FieldSource columnUnit / typeMeaning
database_nameDB_NAME(database_id)sysnameDatabase name derived from database_id
total_log_size_mbtotal_log_size_in_bytes / 1048576decimal MBTotal current allocated log size across all log files
used_log_space_mbused_log_space_in_bytes / 1048576decimal MBActive (non-reclaimable) log bytes — the portion holding open transactions or records still required for recovery
used_log_space_percentused_log_space_in_percentreal (0–100)Used as percentage of total
log_since_last_backup_mblog_space_in_bytes_since_last_backup / 1048576decimal MBLog bytes accumulated since the last LOG backup (SQL Server 2014+ only) — this is the cost signal for log-backup cadence

This query shows whether the transaction log is materially occupied and how much space is waiting for the next log backup.

SELECT
    DB_NAME(database_id) AS database_name,
    CAST(total_log_size_in_bytes / 1024.0 / 1024.0 AS decimal(12,2)) AS total_log_size_mb,
    CAST(used_log_space_in_bytes / 1024.0 / 1024.0 AS decimal(12,2)) AS used_log_space_mb,
    CAST(used_log_space_in_percent AS decimal(6,2)) AS used_log_space_percent,
    CAST(log_space_in_bytes_since_last_backup / 1024.0 / 1024.0 AS decimal(12,2)) AS log_since_last_backup_mb
FROM sys.dm_db_log_space_usage;
database_nametotal_log_size_mbused_log_space_mbused_log_space_percentlog_since_last_backup_mb
stoxx1031.9914.811.434.65

stoxx currently has about 1 GB of log allocation but only 1.43% of it is in use, and just 4.65 MB has accumulated since the last log backup. This is a strong signal that log backups are running on a healthy cadence — the log is being truncated — and that the log file is massively oversized for the actual workload. The FinOps action here is not “back up the log more often” but “shrink the log allocation” (carefully, with DBCC SHRINKFILE and a follow-up right-sizing). A 256 MB log file would comfortably cover this workload, save ~750 MB of persistent disk and every downstream snapshot, and still provide two orders of magnitude of headroom.

ColumnValueWatchMeaningImplication
used_log_space_percent< 10%Log is nearly emptyLog file is oversized; candidate for DBCC SHRINKFILE + re-sizing
used_log_space_percent10–50%Normal operating rangeNo action needed
used_log_space_percent50–80%WatchLog is materially occupiedVerify log backup cadence is keeping up with workload
used_log_space_percent> 80%Log is nearing exhaustionImmediate operational issue; investigate long-running transactions, replication, or missing log backups
log_since_last_backup_mb< 100 MBLog is backed up on a tight cadenceHealthy chain, low cost
log_since_last_backup_mbLarge and rising❌ under FULLLog backup cadence is insufficientDisk growth and snapshot size rise needlessly; risk of log-full error

A nearly-empty log file is a cost signal, not a success signal

When used_log_space_percent stays under a few percent over a long window, the log file is larger than the workload needs. The storage is billed continuously regardless of how much of it is in use. Downsize the log file (carefully — shrink during a maintenance window, not during active workload) and re-grow it only if sustained usage justifies it.

[!success] Right-size the log to the expected peak used-log-space envelope

Target the log size at roughly 2 × peak used_log_space_mb observed over a two-week window, rounded up to a 64-MB multiple. That provides enough headroom for unusual transaction bursts without wasting disk. Never auto-shrink on a schedule — shrinking and regrowing repeatedly fragments VLFs and causes log-write stalls.

Audit VLF count and size distribution for stoxx

After observing slow log backups, slow database startup, or after multiple auto-growth events; before any log-file shrink/regrow operation. It is typically triggered by log file shows signs of fragmentation, frequent auto-growth events in the error log, or before re-sizing the log. Read-only T-SQL against the sys.dm_db_log_info() table-valued function, which requires a database_id argument. Runs from any database context but the argument must identify the target. Requires VIEW DATABASE STATE. Quantify how fragmented the transaction log is at the Virtual Log File level. VLF count matters because too many small VLFs degrade log-operation performance (log backups, replication, database startup), while too few large VLFs make truncation suboptimal.

FieldSource columnUnit / typeMeaning
vlf_countcount(*)intTotal number of virtual log files
vlf_activecount(where vlf_active = 1)intActive VLFs (holding log records that cannot yet be truncated)
vlf_avg_size_mbavg(vlf_size_mb)decimal MBAverage VLF size
vlf_max_size_mbmax(vlf_size_mb)decimal MBLargest VLF size
vlf_total_size_mbsum(vlf_size_mb)decimal MBTotal log size as seen by the VLF breakdown; should match total_log_size_mb from sys.dm_db_log_space_usage

This query returns the VLF count, active VLF count, and size distribution for the stoxx transaction log.

SELECT
    CAST(COUNT(*) AS int)                                               AS vlf_count,
    CAST(SUM(CASE WHEN li.vlf_active = 1 THEN 1 ELSE 0 END) AS int)     AS vlf_active,
    CAST(AVG(CAST(li.vlf_size_mb AS decimal(12,2))) AS decimal(12,2))   AS vlf_avg_size_mb,
    CAST(MAX(CAST(li.vlf_size_mb AS decimal(12,2))) AS decimal(12,2))   AS vlf_max_size_mb,
    CAST(SUM(CAST(li.vlf_size_mb AS decimal(12,2))) AS decimal(12,2))   AS vlf_total_size_mb
FROM sys.dm_db_log_info(DB_ID('stoxx')) AS li;
vlf_countvlf_activevlf_avg_size_mbvlf_max_size_mbvlf_total_size_mb
44123.4564.001031.96

The stoxx log has 44 VLFs averaging 23.45 MB, with a single active VLF. That is a healthy distribution — under the “few hundred VLFs” ceiling that starts causing log-operation slowdowns, and the sizes are consistent with 64 MB fixed auto-growth (SQL Server 2022 rules: growth chunks of 64 MB–1 GB produce 8 VLFs, so 8 growth events ≈ 64 VLFs; this log has grown 5–6 times). The single active VLF confirms the log is being truncated promptly and cost-effectively.

ColumnValueWatchMeaningImplication
vlf_count< 100Healthy logNo action needed
vlf_count100–500WatchModerately fragmentedPlan a controlled shrink + regrow to rebuild the VLF distribution
vlf_count> 500Heavily fragmentedLog backups, replication, and startup measurably slower; shrink + regrow required
vlf_active= 1Log is truncating as expectedLog-backup chain is healthy
vlf_active= vlf_countLog cannot truncateInvestigate log_reuse_wait_desc: open transaction, replication lag, missing log backup
vlf_avg_size_mb< 8 MBOver-fragmented from small percent-growth eventsHistorical percent-growth anti-pattern; rebuild the log

Audit used vs. free space inside each data file for stoxx

After the database-level audit has identified a database that owns material storage cost; before any DBCC SHRINKFILE or file-layout change. It is typically triggered by A data file that appears oversized relative to workload, or preparation for a persistent disk downsize. Read-only T-SQL against stoxx.sys.database_files joined with FILEPROPERTY(..., 'SpaceUsed'). The three-part name is required because sys.database_files is database-scoped — unlike sys.master_files, it only sees files of the current database, so stoxx.sys.database_files reaches into stoxx without a USE statement. Distinguish between allocated and actually-used space inside each file. This is the signal that decides whether a file is oversized (shrink candidate) or genuinely full (grow candidate).

FieldSourceUnit / typeMeaning
logical_namesys.database_files.namesysnameLogical name of the file
type_descsys.database_files.type_descnvarchar(60)ROWS or LOG
allocated_mbsize * 8 / 1024decimal MBTotal allocated file size
used_mbFILEPROPERTY(name, 'SpaceUsed') * 8 / 1024decimal MBPages actually used inside the file
free_mb(size - FILEPROPERTY(name, 'SpaceUsed')) * 8 / 1024decimal MBUnused pages inside the file
used_percent100 * used / allocateddecimal %Fill ratio

This query returns the allocated, used, and free space inside every ROWS and LOG file of the stoxx database.

SELECT
    df.name AS logical_name,
    df.type_desc,
    CAST(df.size * 8.0 / 1024 AS decimal(12,2)) AS allocated_mb,
    CAST(CAST(FILEPROPERTY(df.name, 'SpaceUsed') AS bigint) * 8.0 / 1024 AS decimal(12,2)) AS used_mb,
    CAST((df.size - CAST(FILEPROPERTY(df.name, 'SpaceUsed') AS bigint)) * 8.0 / 1024 AS decimal(12,2)) AS free_mb,
    CAST(
        CASE WHEN df.size = 0 THEN 0
             ELSE 100.0 * CAST(FILEPROPERTY(df.name, 'SpaceUsed') AS bigint) / df.size
        END AS decimal(6,2)
    ) AS used_percent
FROM stoxx.sys.database_files AS df
WHERE df.type IN (0, 1)
ORDER BY df.type, df.file_id;
logical_nametype_descallocated_mbused_mbfree_mbused_percent
stoxxROWS712.00597.69114.3183.94
stoxx_logLOG1032.0014.801017.201.43

The data file is 83.94% full, which is healthy — it has enough headroom for normal operation and is not a shrink candidate. The log file is 1.43% full, which confirms the previous finding: 1 GB allocation for a workload that actively uses 15 MB of log. The combined picture says “the data file is sized about right; the log file is 4× too big.” Both findings feed directly into persistent-disk right-sizing: if the data file is 83.94% full now, the disk should have headroom for one growth generation (64 MB) plus one month of expected data growth, and the log file can be shrunk to 256 MB without risk.

ColumnValueWatchMeaningImplication
used_percent (ROWS)< 50%WatchData file is sparsely populatedCandidate for shrink, but only with care — shrinking fragments indexes
used_percent (ROWS)50–85%Healthy operating rangeNo action
used_percent (ROWS)> 90%File is nearly fullGrow proactively before next major load
used_percent (LOG)< 10%✅ costLog is oversizedCandidate for shrink + right-size
used_percent (LOG)> 80%Log cannot truncateInvestigate log_reuse_wait_desc

Audit tempdb file layout and growth

During initial instance configuration review or after observing PAGELATCH contention on tempdb allocation pages. It is typically triggered by new instance under FinOps management, or performance investigation flagging PAGELATCH_EX on 2:1:1 / 2:1:2 / 2:1:3 (GAM/SGAM/PFS of tempdb). Read-only T-SQL against sys.master_files filtered to DB_ID('tempdb'). The setting of file count and size is instance-level (via startup parameters or ALTER DATABASE), and it survives restart. Confirm that tempdb has the right number of equally-sized data files for the core count and that the growth setting is sensible. tempdb is a cost signal because it is the one database whose storage is actively re-used and whose I/O characteristics drive persistent-disk IOPS choice.

FieldSource columnUnit / typeMeaning
logical_namesys.master_files.namesysnameLogical file name (typically tempdev, tempdev2, etc.)
type_descsys.master_files.type_descnvarchar(60)ROWS or LOG
size_mbsize * 8 / 1024decimal MBInitial file size — seeded at instance startup
growth_settinggrowth + is_percent_growthvarcharFixed MB or percentage
is_read_onlyis_read_onlybitAlways 0 for tempdb

This query returns every tempdb file with its initial size and growth setting.

SELECT
    mf.name AS logical_name,
    mf.type_desc,
    CAST(mf.size * 8.0 / 1024 AS decimal(12,2)) AS size_mb,
    CASE WHEN mf.is_percent_growth = 1 THEN CONCAT(mf.growth, ' %')
         ELSE CONCAT(CAST(mf.growth * 8.0 / 1024 AS decimal(12,2)), ' MB') END AS growth_setting,
    mf.is_read_only
FROM sys.master_files AS mf
WHERE mf.database_id = DB_ID('tempdb')
ORDER BY mf.type, mf.file_id;
logical_nametype_descsize_mbgrowth_settingis_read_only
tempdevROWS8.0064.00 MBFalse
tempdev2ROWS8.0064.00 MBFalse
tempdev3ROWS8.0064.00 MBFalse
tempdev4ROWS8.0064.00 MBFalse
tempdev5ROWS8.0064.00 MBFalse
tempdev6ROWS8.0064.00 MBFalse
tempdev7ROWS8.0064.00 MBFalse
tempdev8ROWS8.0064.00 MBFalse
templogLOG8.0064.00 MBFalse

stoxx has 8 tempdb data files of 8 MB each, plus a single 8 MB log file, all with 64 MB fixed growth. The 8-file count is the standard recommendation for up to 8 logical CPUs — any more files than cores yields diminishing returns and can cause its own contention. The initial 8 MB size is small: on a real workload, tempdb will auto-grow almost immediately, and repeated auto-grow events stall sessions waiting on the grow. The cost-conscious fix is to pre-allocate tempdb files to their expected steady-state size (e.g., 512 MB each) and leave auto-grow as a safety net, not a hot path.

ColumnValueWatchMeaningImplication
size_mb (ROWS)Uniform across filesProportional-fill allocator works correctlyNo allocation skew
size_mb (ROWS)UnequalAllocation skew — one file gets most writesSize all tempdb data files identically
File count (ROWS)1❌ on > 1 coreGuaranteed PAGELATCH contentionMatch file count to cores (up to 8)
File count (ROWS)= cores up to 8Standard recommendationNo action
growth_settingPercentPercent-growth on tempdb causes unpredictable auto-grow stallsSwitch to fixed-MB

Backup Storage Footprint And Compression

Backup storage is the second place FinOps gains compound. SQL Server already produces a detailed history of every backup it takes — size, duration, and compression ratio — inside msdb.dbo.backupset. The queries below surface that history and then enable backup compression by default so new backups benefit automatically.

Audit historical backup size and observed compression ratio

As part of any FinOps storage review; before changing backup cadence, retention, or GCS storage class; after migrating backup jobs. It is typically triggered by growing GCS backup bucket cost, observed inconsistencies in backup size, or before enabling backup compression by default. Read-only T-SQL against msdb.dbo.backupset. Runs in any session with db_owner or SELECT on the msdb backup tables (typically granted via the db_backupoperator role in msdb). Produce a realistic picture of recent backup sizes, both compressed and uncompressed, to compute the actual compression ratio and verify backup type mix (full / differential / log).

FieldSource columnUnit / typeMeaning
database_namemsdb.dbo.backupset.database_namenvarchar(128)Database the backup set belongs to
backup_typetype (char(1)) mapped via CASEvarcharD=Full, I=Differential, L=Log, F=File/Filegroup, G=Differential file, P=Partial, Q=Differential partial
recovery_modelrecovery_modelnvarchar(60)Recovery model at the time of the backup
uncompressed_mbbackup_size / 1048576decimal MBUncompressed bytes (estimated for VSS backups)
compressed_mbcompressed_backup_size / 1048576decimal MBActual on-disk bytes. Identical to backup_size when compression is off
compression_ratiobackup_size / compressed_backup_sizedecimalCompression ratio (2.0 = half the size, 6.0 = six times smaller)
duration_secDATEDIFF(SECOND, start, finish)intBackup duration — CPU cost proxy for compression
backup_finish_datebackup_finish_datedatetimeWhen the backup completed

This query returns the 10 most recent backups with their type, compression ratio, and duration.

SELECT TOP (10)
    database_name,
    CASE type
        WHEN 'D' THEN 'Full'
        WHEN 'I' THEN 'Differential'
        WHEN 'L' THEN 'Log'
        WHEN 'F' THEN 'File/Filegroup'
        WHEN 'G' THEN 'Diff File/Filegroup'
        WHEN 'P' THEN 'Partial'
        WHEN 'Q' THEN 'Diff Partial'
    END AS backup_type,
    recovery_model,
    CAST(backup_size / 1048576.0 AS decimal(12,2)) AS uncompressed_mb,
    CAST(compressed_backup_size / 1048576.0 AS decimal(12,2)) AS compressed_mb,
    CAST(CAST(backup_size AS float)
         / NULLIF(compressed_backup_size, 0) AS decimal(6,2)) AS compression_ratio,
    CAST(DATEDIFF(SECOND, backup_start_date, backup_finish_date) AS int) AS duration_sec,
    backup_finish_date
FROM msdb.dbo.backupset
ORDER BY backup_finish_date DESC;
database_namebackup_typerecovery_modeluncompressed_mbcompressed_mbcompression_ratioduration_secbackup_finish_date
codex_tde_demoFullFULL3.160.486.6202026-04-11 17:18:50
stoxxLogFULL0.120.033.3902026-04-11 16:32:21
stoxxLogFULL0.300.103.1502026-04-11 16:32:19
stoxxFullFULL620.3695.696.4812026-04-11 16:31:08
stoxxFullFULL598.1395.596.2612026-04-11 16:31:06
stoxxFullFULL600.2495.596.2802026-04-11 16:30:48
stoxxLogFULL0.120.052.5202026-04-11 16:30:24
stoxxLogFULL9.122.683.4102026-04-11 16:30:18
stoxxDifferentialFULL2.130.1414.8202026-04-11 16:30:18
stoxxFullFULL598.1395.496.2612026-04-11 16:29:55

The compression story here is striking. Full backups of stoxx compress at a consistent 6.26–6.48:1 ratio — a 598 MB uncompressed full backup becomes ~95.5 MB on disk, saving ~502 MB per backup. Differential backups compress even harder at 14.82:1 because differentials contain mostly changed pages with high value locality. Log backups compress at a more modest 2.52–3.41:1 because log records are already dense. Every one of these backups has compressed_mb << uncompressed_mb, which means some process — explicit WITH COMPRESSION in the backup job — is already producing compressed backups, even though backup compression default is still 0 at the instance level (verified in the next query). The natural next step is to flip the default to 1 so any new backup job inherits compression without needing to remember WITH COMPRESSION.

ColumnValueWatchMeaningImplication
compression_ratio> 4Compression is materially effectiveStrong case for default compression
compression_ratio2–4Typical ratioStill worth enabling by default
compression_ratio~1Compression is ineffectiveData is already compressed/encrypted, or TDE + MAXTRANSFERSIZE interaction (see callout below)
backup_type = LogAny✅ under FULLLog-chain backups are runningConfirms recoverability chain
backup_type = LogAbsent in recent history❌ under FULLNo log backups in the visible windowLog truncation blocked, disk growth inevitable
duration_secProportional to sizeCompression CPU cost is acceptableNo resource governor needed

TDE + backup compression need MAXTRANSFERSIZE > 64 KB

When a database is TDE-encrypted, the default MAXTRANSFERSIZE of 65536 (64 KB) disables the optimized decrypt-compress-encrypt-per-page path, and compression ratios collapse from ~6:1 to near 1:1. SQL Server 2019 CU5 and later auto-elevate MAXTRANSFERSIZE to 128 KB whenever WITH COMPRESSION is used or backup compression default = 1. Older builds require an explicit WITH MAXTRANSFERSIZE = 131072 on every backup command.

[!success] Upgrade to 2019 CU5+ or specify MAXTRANSFERSIZE explicitly

  • On SQL Server 2019 CU5 or newer: do nothing — the elevation is automatic.
  • On older builds with TDE: set MAXTRANSFERSIZE = 131072 on every BACKUP DATABASE / BACKUP LOG statement, or wrap the backup call in a stored procedure that enforces it.
  • Verify after the change by comparing backup_size to compressed_backup_size on a new full backup of the TDE database.

Audit current backup compression default setting

Before deciding whether to enable backup compression by default, as part of instance configuration review, or after restoring system databases. It is typically triggered by backup compression default may have drifted from the platform standard (typically 1), or a new instance has not been configured yet. Read-only T-SQL against sys.configurations. The query also audits related memory and parallelism knobs because they interact with compression CPU cost. Requires VIEW SERVER STATE. value and value_in_use are sql_variant columns and must be cast to a concrete type (here int) before pyodbc can consume them. Confirm the current instance-level setting before deciding whether to flip it, and surface neighbouring memory/MAXDOP knobs that often drift at the same time.

FieldSource columnUnit / typeMeaning
namesys.configurations.namenvarchar(35)Configuration option name
valuesys.configurations.valuesql_variant → intValue as set (pending RECONFIGURE)
value_in_usesys.configurations.value_in_usesql_variant → intValue currently in effect
descriptionsys.configurations.descriptionnvarchar(255)Human-readable description

This query returns the current value of backup compression default alongside four other cost-relevant instance knobs.

SELECT
    name,
    CAST(value        AS int) AS value,
    CAST(value_in_use AS int) AS value_in_use,
    CAST(description  AS varchar(200)) AS description
FROM sys.configurations
WHERE name IN (
    N'backup compression default',
    N'max server memory (MB)',
    N'min server memory (MB)',
    N'cost threshold for parallelism',
    N'max degree of parallelism'
)
ORDER BY name;
namevaluevalue_in_usedescription
backup compression default00Enable compression of backups by default
cost threshold for parallelism55cost threshold for parallelism
max degree of parallelism00maximum degree of parallelism
max server memory (MB)21474836472147483647Maximum size of server memory (MB)
min server memory (MB)016Minimum size of server memory (MB)

Four of the five knobs are sitting at install defaults. backup compression default = 0 means any backup job that forgets WITH COMPRESSION produces uncompressed backups — the previous query showed jobs that do remember it get 6:1 on full backups, so the savings are genuinely on the table. max server memory (MB) = 2147483647 (the int32 maximum) is the uncapped default and is the biggest cost/stability signal on this instance — without a cap, SQL Server will grow buffer pool until the Linux container is OOM-killed. max degree of parallelism = 0 means unlimited (uses all logical CPUs), which is reasonable for a small workload but risky for mixed OLTP/analytics on a shared host. cost threshold for parallelism = 5 is the 1998-era default and is almost always wrong for modern CPUs — 50 is the typical starting point. None of these four defaults affect cost directly the way backup compression does, but they affect the headroom available to turn compression on without stability risk.

ColumnValueWatchMeaningImplication
backup compression default0Compression is opt-in per backup commandEasy for jobs and scripts to miss the cost-saving option
backup compression default1Compression is the default behaviourLower routine backup storage footprint
max server memory (MB)2147483647 (uncapped)Buffer pool can consume all host RAMOOM risk on containerised or shared hosts; cap to leave headroom for OS + other processes
max server memory (MB)Sized to host RAM minus OS/otherInstance memory is boundedStable memory footprint; safe baseline for compression CPU work
max degree of parallelism0WatchUnlimited parallelismFine for small workloads, risky for mixed loads; cap to ≤ 8 on most OLTP estates
cost threshold for parallelism51998 defaultRaise to 25–50 for modern CPUs to avoid trivial queries going parallel

Enable backup compression by default

After the historical backup audit has shown that observed compression ratios are ≥ 2 and CPU headroom exists during the backup window. It is typically triggered by instance-level standardization, new instance bring-up, or cost review finding that backup jobs forget WITH COMPRESSION. State-changing T-SQL against sp_configure followed by RECONFIGURE. Instance-level. Dynamic — takes effect immediately on the next backup command without a restart. Requires the ALTER SETTINGS server-level permission (held by sysadmin and serveradmin). Make compressed backups the default behaviour so every job inherits compression automatically. Explicit WITH NO_COMPRESSION in an individual backup command still overrides the default.

Backup compression is CPU-heavy; validate before enabling in production

Backup compression using MS_XPRESS (the default since SQL Server 2008) significantly increases CPU usage during the backup window. On a CPU-constrained VM running concurrent workloads, enabling compression by default can make backups noticeably slower or steal CPU from the application. The Microsoft guidance is to use Resource Governor to cap backup CPU, but Resource Governor is Enterprise-only — on Standard edition, run backups during a quiet window instead.

[!success] Verify CPU headroom during the backup window, then enable

Run a compressed full backup manually during peak workload and monitor CPU. If CPU stays below the threshold your latency SLO tolerates (typically below ~80%), enable compression by default. If not, keep compression opt-in and enable it per job, scheduled during off-peak hours. For TDE-encrypted databases, confirm that MAXTRANSFERSIZE is at least 128 KB (automatic on SQL Server 2019 CU5+, manual on older builds — see the TDE callout above).

This command enables backup compression at the instance level.

EXEC sp_configure 'backup compression default', 1;
RECONFIGURE;

This command re-reads the setting to confirm the change took effect.

SELECT
    name,
    CAST(value        AS int) AS value,
    CAST(value_in_use AS int) AS value_in_use
FROM sys.configurations
WHERE name = N'backup compression default';
SettingWhat it controlsDefaultPossible valuesProduction guidance
backup compression defaultWhether new backup commands compress by default00 (off), 1 (on)1 on most estates; 0 only when CPU is constrained and every backup job already sets WITH COMPRESSION explicitly
backup compression algorithm (2022+)Compression algorithm used1 (MS_XPRESS)0 (off), 1 (MS_XPRESS), 2 (Intel QAT)1 is the baseline; 2 only with supported QAT hardware, which is irrelevant on GCE
MAXTRANSFERSIZE (per backup)I/O buffer size65536 (64 KB) for most, 1048576 (1 MB) for URL655364194304131072 (128 KB) minimum for TDE + compression on pre-2019-CU5 builds

Data Compression As A Storage Lever

Backup compression reduces the size of backups. Data compression (row, page, columnstore archival) reduces the size of the live database itself — every read, every write, every backup, and every snapshot scales with the compressed footprint. For SQL Server 2016 SP1 and later, row and page compression are available on all editions, so edition-gating is no longer a barrier on recent builds.

Audit current data compression coverage for user tables in stoxx

Before deciding whether to apply data compression; as part of periodic storage reviews; after a schema migration. It is typically triggered by persistent disk cost is material and the backup compression lever has already been pulled; or a specific table is known to be a hot spot. Read-only T-SQL against stoxx.sys.partitions joined with sys.indexes, sys.tables, sys.schemas, and sys.allocation_units. Runs in any database context because of the three-part names. Requires VIEW DEFINITION at the database level. Identify which tables and indexes are already compressed, which are not, and how much space each uncompressed structure owns — ranked by size so the biggest candidates surface first.

FieldSourceUnit / typeMeaning
schema_namesys.schemas.namesysnameSchema owner of the table
table_namesys.tables.namesysnameTable name
index_typesys.indexes.type_descnvarchar(60)HEAP, CLUSTERED, NONCLUSTERED, CLUSTERED COLUMNSTORE, NONCLUSTERED COLUMNSTORE
data_compression_descsys.partitions.data_compression_descnvarchar(60)NONE, ROW, PAGE, COLUMNSTORE, COLUMNSTORE_ARCHIVE
total_rowssum(sys.partitions.rows)bigintTotal rows across partitions
total_mbsum(sys.allocation_units.total_pages) * 8 / 1024decimal MBAllocated space across all allocation units (IN_ROW_DATA + ROW_OVERFLOW + LOB)

This query returns every user-table structure in stoxx with its current compression state and size, sorted largest first.

SELECT
    s.name AS schema_name,
    t.name AS table_name,
    i.type_desc AS index_type,
    p.data_compression_desc,
    CAST(SUM(p.rows) AS bigint) AS total_rows,
    CAST(SUM(au.total_pages) * 8.0 / 1024 AS decimal(12,2)) AS total_mb
FROM stoxx.sys.partitions AS p
JOIN stoxx.sys.indexes AS i
    ON i.object_id = p.object_id AND i.index_id = p.index_id
JOIN stoxx.sys.tables AS t
    ON t.object_id = p.object_id
JOIN stoxx.sys.schemas AS s
    ON s.schema_id = t.schema_id
JOIN stoxx.sys.allocation_units AS au
    ON au.container_id = p.partition_id
WHERE t.is_ms_shipped = 0
GROUP BY s.name, t.name, i.type_desc, p.data_compression_desc
ORDER BY total_mb DESC;
schema_nametable_nameindex_typedata_compression_desctotal_rowstotal_mb
dbodemo_idxmaint_rowstoreCLUSTEREDNONE671550376.88
dbodemo_idxmaint_missingCLUSTEREDNONE67155094.07
dbodemo_idxmaint_rowstoreNONCLUSTEREDNONE67155036.13
dbodemo_idxmaint_splitsCLUSTEREDNONE10000023.07
dbodemo_eurostoxx50_ohlcvCLUSTEREDNONE671557.32
silvereurostoxx50_ohlcvCLUSTEREDNONE671556.07
silverstoxxasia50_ohlcvCLUSTEREDNONE648755.82
silverstoxxusa50_ohlcvCLUSTEREDNONE660005.82
dbodemo_idxmaint_usageNONCLUSTEREDNONE1000002.77
silveroil20_ohlcvCLUSTEREDNONE250802.20
dbodemo_idxmaint_columnstoreCLUSTERED COLUMNSTORECOLUMNSTORE2594322.13

Out of every user-table structure in stoxx, exactly one is compressed — dbo.demo_idxmaint_columnstore, and only because columnstore compression is always on for columnstore indexes. Everything else, including the largest single structure (dbo.demo_idxmaint_rowstore at 376.88 MB), is NONE. That is ~500 MB of rowstore data where page compression has not been evaluated. The next query estimates how much of that space page compression would actually recover, and confirms the expected savings before committing to any change.

ColumnValueWatchMeaningImplication
data_compression_descNONE on large tablesWatchCompression lever is still availableRun sp_estimate_data_compression_savings before deciding
data_compression_descROWLowest CPU cost, ~20–30% savingsGood default for OLTP tables with hot writes
data_compression_descPAGEHigher savings (30–60%) at higher CPU costGood for read-heavy tables and large historical data
data_compression_descCOLUMNSTOREAlways-on for columnstoreTypical savings 5–10× vs rowstore
data_compression_descCOLUMNSTORE_ARCHIVEExtra xpress layer on columnstoreSlow to access; for cold partitions only

Estimate page compression savings on the largest silver table

After identifying a specific table as a compression candidate from the coverage audit. It is typically triggered by A user table with data_compression_desc = NONE that is large enough to matter (typically > 50 MB) and is accessed predominantly via seeks rather than full scans. Read-only-ish T-SQL via sp_estimate_data_compression_savings. The stored procedure acquires an intent-shared (IS) lock on the table, reads a sample into tempdb, and runs the compression algorithm on the sample to estimate the final size. Does not actually compress anything. Can produce non-trivial tempdb and CPU load on large tables — avoid running at peak hours on very large tables. Produce an evidence-based estimate of storage savings from page compression before committing to a rebuild. Works for row, page, columnstore, and columnstore archival.

ParameterTypeMeaning
@schema_namesysnameSchema of the target object
@object_namesysnameName of the target table or indexed view
@index_idint / NULLSpecific index to estimate, or NULL for all indexes
@partition_numberint / NULLSpecific partition, or NULL for all partitions
@data_compressionnvarchar(60)NONE, ROW, PAGE, COLUMNSTORE, COLUMNSTORE_ARCHIVE

This command estimates how much space silver.eurostoxx50_ohlcv would save under page compression.

USE stoxx;
EXEC sp_estimate_data_compression_savings
    @schema_name      = N'silver',
    @object_name      = N'eurostoxx50_ohlcv',
    @index_id         = NULL,
    @partition_number = NULL,
    @data_compression = N'PAGE';
object_nameschema_nameindex_idpartition_numbersize_with_current_compression_setting(KB)size_with_requested_compression_setting(KB)sample_size_with_current_compression_setting(KB)sample_size_with_requested_compression_setting(KB)
eurostoxx50_ohlcvsilver116160372864083880
eurostoxx50_ohlcvsilver211928112818801104

Page compression would shrink the clustered index of silver.eurostoxx50_ohlcv from 6,160 KB to 3,728 KB — a 39.5% reduction — and the nonclustered index from 1,928 KB to 1,128 KB — a 41.5% reduction. In absolute terms the savings are small because the table itself is small, but the ratio is representative: on numeric time-series data like OHLCV, page compression typically recovers 35–50% of allocated space. Applying the same logic to the larger dbo.demo_idxmaint_rowstore at 376.88 MB would recover roughly 150 MB of persistent disk per rebuild.

The ROW-compression estimate on the same table (run separately) produced 31% savings on the clustered index and 18% on the nonclustered — noticeably lower than PAGE, which is the expected trade: row compression has a lower CPU cost at query time but captures only the fixed-type bit-packing gains, while page compression adds prefix + dictionary layers that dominate on OHLCV data.

ColumnValueWatchMeaningImplication
size_with_requested / size_with_current< 0.5Material savings availableStrong case for compression
size_with_requested / size_with_current0.5–0.8WatchModerate savingsWeigh CPU cost; ROW may be the better trade
size_with_requested / size_with_current> 0.9Compression not worth itData already compressed / encrypted / high-entropy
size_with_requested = size_with_currentexactlyCompression inapplicableUsually a columnstore or already-compressed structure

Apply page compression to a candidate table

After the estimate has confirmed meaningful savings and a maintenance window is scheduled. It is typically triggered by compression estimate shows > 30% savings and no blocker (CPU headroom, locking window, Enterprise-only feature dependency). State-changing T-SQL. ALTER TABLE ... REBUILD WITH (DATA_COMPRESSION = PAGE) performs an offline rebuild of the heap or clustered index plus all nonclustered indexes. On Enterprise edition, use ONLINE = ON for non-blocking rebuild; on Standard, the rebuild takes a schema-modification lock on the table for the duration. Requires ALTER permission on the table. Apply page compression to the base table and all its nonclustered indexes in a single statement, so subsequent reads, writes, and backups operate on the compressed footprint.

Offline rebuild is blocking on Standard edition

On SQL Server Standard, ALTER TABLE ... REBUILD holds a schema-modification (SCH-M) lock on the table for the duration of the rebuild, which blocks all readers and writers. Online index rebuild is an Enterprise-only feature. A 376 MB table rebuild can take minutes on a loaded host — schedule during a maintenance window.

[!success] Use ONLINE=ON on Enterprise, or partition the table on Standard

  • On Enterprise: add WITH (DATA_COMPRESSION = PAGE, ONLINE = ON) to keep the table readable during rebuild.
  • On Standard: if the table is partitioned, rebuild one partition at a time with REBUILD PARTITION = N to bound the lock window.
  • On Standard without partitioning: accept the outage window and schedule it with application owners.

This command applies page compression to every partition of silver.eurostoxx50_ohlcv, including nonclustered indexes, in a single rebuild.

ALTER TABLE silver.eurostoxx50_ohlcv
REBUILD PARTITION = ALL
WITH (DATA_COMPRESSION = PAGE);

This command applies page compression to a specific nonclustered index only, useful when the base table and index have different access patterns.

ALTER INDEX IX_silver_eurostoxx50_ohlcv_symbol_date
ON silver.eurostoxx50_ohlcv
REBUILD WITH (DATA_COMPRESSION = PAGE);
OptionValuesDefaultMeaning
DATA_COMPRESSIONNONE, ROW, PAGE, COLUMNSTORE, COLUMNSTORE_ARCHIVENONECompression scheme to apply
ONLINE (Enterprise)ON, OFFOFFWhether the rebuild blocks readers/writers
MAXDOPint0 (instance default)Parallelism cap for the rebuild
REBUILD PARTITIONALL or NRebuild all partitions or one specific partition
RESUMABLE (Enterprise, 2017+)ON, OFFOFFAllow pausing and resuming the rebuild

Audit persisted Enterprise-only features before changing edition

Before planning an edition change (Enterprise → Standard), a migration to a lower-tier Cloud SQL instance, or a licensing-cost audit. It is typically triggered by cost review asking whether the workload can be downgraded to Standard; licensing renewal; migration planning. Read-only T-SQL against sys.dm_db_persisted_sku_features, which is database-scoped — it must be queried in the context of each user database. Requires VIEW DATABASE STATE (2019-) or VIEW DATABASE PERFORMANCE STATE (2022+). Identify features currently enabled in the database that would block restore to a lower edition, so the licensing conversation is grounded in facts.

FieldSource columnUnit / typeMeaning
feature_namefeature_namesysnameFeature name (e.g., ChangeCapture, ColumnStoreIndex, Compression, InMemoryOLTP, Partitioning, TransparentDataEncryption)
feature_idfeature_idintInternal feature identifier

This query returns every Enterprise-gated persisted feature currently enabled in stoxx.

SELECT feature_name, feature_id
FROM stoxx.sys.dm_db_persisted_sku_features;
feature_namefeature_id
ColumnStoreIndex600

stoxx reports only one persisted feature — ColumnStoreIndex — and since SQL Server 2016 SP1 this feature is available on Standard edition. In other words, stoxx has no real edition blocker: it can be restored to Standard without removing anything. The only pre-2016-SP1 features that would block a downgrade on modern builds are TransparentDataEncryption (still Enterprise/Standard only, blocked on Web/Express) and MultipleFSContainers (multi-FILESTREAM containers). For a cost review, this is a green light to evaluate Standard pricing for this workload.

ColumnValueWatchMeaningImplication
feature_nameColumnStoreIndex✅ since 2016 SP1Not a blocker on modern buildsIgnorable for Standard migration
feature_nameCompression✅ since 2016 SP1Not a blocker on modern buildsIgnorable
feature_namePartitioning✅ since 2016 SP1Not a blocker on modern buildsIgnorable
feature_nameInMemoryOLTP✅ since 2016 SP1Not a blocker on modern buildsStandard caps at 32 GB per database — check size
feature_nameChangeCaptureStandard supports CDCIgnorable
feature_nameTransparentDataEncryption❌ on Web/ExpressBlocks downgrade below StandardMust decrypt before restore
feature_nameMultipleFSContainersBlocks StandardMust remove FILESTREAM containers before restore

Volume Headroom And Disk Sizing

The final storage signal is what the volume itself looks like. SQL Server reports the OS-level volume metadata that backs every file it owns, which is the bridge between the database allocation numbers and the persistent-disk bill.

Audit persistent volume capacity and free space

Before resizing a persistent disk up or down; during capacity planning; after observing growth alerts. It is typically triggered by approaching disk full, or the opposite — a disk that is oversized relative to the data it contains. Read-only T-SQL against sys.dm_os_volume_stats(). The function is called via CROSS APPLY because it takes (database_id, file_id) parameters that come from sys.master_files. On Linux and containerised SQL Server deployments, Windows-specific metadata columns (volume_mount_point, file_system_type, supports_compression, is_compressed) return NULL, but total_bytes and available_bytes are always populated. Show how much physical volume is under the instance and how much of it is free, so persistent disk right-sizing is grounded in the OS-level numbers the hypervisor bills on.

FieldSource columnUnit / typeMeaning
volume_mount_pointsys.dm_os_volume_stats.volume_mount_pointnvarchar(512)Mount point (NULL on Linux)
file_system_typesys.dm_os_volume_stats.file_system_typenvarchar(512)NTFS, ReFS, ext4, etc. (NULL on Linux)
total_gbtotal_bytes / 1024^3decimal GBTotal volume size — always populated
free_gbavailable_bytes / 1024^3decimal GBAvailable space — always populated
free_percent100 * available / totaldecimal %Free space as percentage
supports_compressionsupports_compressiontinyintNTFS-style compression support (NULL on Linux)
is_compressedis_compressedtinyintWhether the volume is NTFS-compressed (NULL on Linux)

This query returns the volume statistics as SQL Server sees them, deduplicated across all database files.

SELECT DISTINCT TOP (10)
    vs.volume_mount_point,
    vs.file_system_type,
    CAST(vs.total_bytes / 1024.0 / 1024 / 1024 AS decimal(12,2)) AS total_gb,
    CAST(vs.available_bytes / 1024.0 / 1024 / 1024 AS decimal(12,2)) AS free_gb,
    CAST(100.0 * vs.available_bytes / NULLIF(vs.total_bytes, 0) AS decimal(6,2)) AS free_percent,
    vs.supports_compression,
    vs.is_compressed
FROM sys.master_files AS mf
CROSS APPLY sys.dm_os_volume_stats(mf.database_id, mf.file_id) AS vs
ORDER BY vs.volume_mount_point;
volume_mount_pointfile_system_typetotal_gbfree_gbfree_percentsupports_compressionis_compressed
NULLNULL1006.85921.7091.54NULLNULL

SQL Server sees one volume with a total of 1006.85 GB and 921.70 GB free — 91.54% free. The NULL mount metadata is a Linux/container detail, not a broken DMV: Windows-specific GetVolumeInformation APIs are unavailable on Linux and those columns return NULL while the byte counts remain populated. On GCP, a 1 TB persistent disk with only ~85 GB used is the single most obvious cost-reduction opportunity in this environment: the pd-balanced price is roughly 100/month. Right-sizing to 256 GB (still massive headroom over the 85 GB used) would save ~$75/month per instance, before any compression gains. The catch is that GCP persistent disks cannot shrink in place — the only way to recover this space is to provision a new smaller disk, copy the data over, and delete the old one (see “Persistent disk choice” in the GCP section).

ColumnValueWatchMeaningImplication
free_percent> 80%❌ costDisk is grossly oversizedRebuild onto a smaller disk — expected savings linear with size reduction
free_percent30–80%Healthy headroomNo action
free_percent10–30%WatchApproaching capacityPlan growth
free_percent< 10%Near-fullImmediate action required — grow or offload cold data
file_system_type = NULLon LinuxExpected Linux/container behaviourNot a DMV fault
is_compressed = 1on NTFS❌ for data filesNTFS-compressed SQL Server files are unsupportedDecompress immediately

Memory And Parallelism As Cost Amplifiers

The last category of in-database signals are the settings that decide how much of the VM SQL Server is allowed to use. These do not directly appear on the disk bill, but they determine whether the current VM shape is the right one — and therefore whether committed use discounts are being spent on capacity the workload does not need.

Audit current process memory and buffer pool state

Before right-sizing the VM, before capping max server memory, or after observing OOM events. It is typically triggered by cost review considering a VM downsize; stability incident; preparing a right-sizing recommendation to present to platform. Read-only T-SQL against sys.dm_os_process_memory. Requires VIEW SERVER STATE (2019-) or VIEW SERVER PERFORMANCE STATE (2022+). This DMV reports memory from SQL Server’s process perspective, distinct from sys.dm_os_sys_memory which reports host-level metrics. Measure the actual memory footprint SQL Server is currently consuming so right-sizing decisions are grounded in observed usage, not in max server memory settings that may be far above what the workload needs.

FieldSource columnUnit / typeMeaning
physical_mem_mbphysical_memory_in_use_kb / 1024decimal MBPhysical memory actually in use by the SQL Server process
large_page_alloc_mblarge_page_allocations_kb / 1024decimal MBLarge-page allocations (used on systems with LPIM and the large pages lock privilege)
locked_page_alloc_mblocked_page_allocations_kb / 1024decimal MBPages locked in memory by Lock Pages in Memory
vas_reserved_mbvirtual_address_space_reserved_kb / 1024decimal MBVirtual address space reserved
vas_committed_mbvirtual_address_space_committed_kb / 1024decimal MBVirtual address space committed
vas_available_mbvirtual_address_space_available_kb / 1024decimal MBVirtual address space still available
process_physical_memory_lowprocess_physical_memory_lowbit1 when the process has been signalled that physical memory is low
process_virtual_memory_lowprocess_virtual_memory_lowbit1 when the process has been signalled that virtual memory is low

This query returns the current memory footprint of the SQL Server process.

SELECT
    CAST(physical_memory_in_use_kb  / 1024.0 AS decimal(12,2)) AS physical_mem_mb,
    CAST(large_page_allocations_kb  / 1024.0 AS decimal(12,2)) AS large_page_alloc_mb,
    CAST(locked_page_allocations_kb / 1024.0 AS decimal(12,2)) AS locked_page_alloc_mb,
    CAST(virtual_address_space_reserved_kb  / 1024.0 AS decimal(12,2)) AS vas_reserved_mb,
    CAST(virtual_address_space_committed_kb / 1024.0 AS decimal(12,2)) AS vas_committed_mb,
    CAST(virtual_address_space_available_kb / 1024.0 AS decimal(12,2)) AS vas_available_mb,
    process_physical_memory_low,
    process_virtual_memory_low
FROM sys.dm_os_process_memory;
physical_mem_mblarge_page_alloc_mblocked_page_alloc_mbvas_reserved_mbvas_committed_mbvas_available_mbprocess_physical_memory_lowprocess_virtual_memory_low
4226.00130.000.004096.001941.6867104767.94FalseFalse

The SQL Server process currently uses about 4.2 GB of physical memory, with 1.94 GB committed in virtual address space. Neither memory-low flag is set. Remember that max server memory is currently uncapped (2147483647 MB) — so the 4.2 GB footprint is what the workload demanded, not a ceiling. For right-sizing: if sustained peak memory stays under 6 GB, an n2-standard-2 VM (8 GB RAM) with max server memory capped at 6 GB is sufficient. If peaks reach 14 GB, step up to n2-standard-4 (16 GB). Either way, the cap must be set before committing to a smaller VM, not after, or the new VM will be OOM-killed the first time the workload spikes.

ColumnValueWatchMeaningImplication
physical_mem_mbBelow max server memoryMemory cap is not yet bindingWorkload has headroom
physical_mem_mbClose to max server memoryWatchCap is binding; checkpoint, sort, and plan cache pressure possibleMonitor PLE and sys.dm_os_memory_clerks
process_physical_memory_low1Host memory is constrainedSQL Server may be releasing buffer pages; investigate other consumers
vas_available_mb< 100 MBVirtual address space is exhausted64-bit should not hit this; investigate host/container memory

Audit plan cache size

After observing high compile times, during memory investigations, or as part of capacity review. It is typically triggered by plan cache growth is suspected of pressuring buffer pool; or the workload is ad-hoc-heavy and plan cache bloat is plausible. Read-only T-SQL against sys.dm_exec_cached_plans. Requires VIEW SERVER STATE. Lightweight query — a single aggregate. Quantify how much memory is currently held by cached plans, as one of the cost-relevant signals for deciding between OPTIMIZE FOR AD HOC WORKLOADS and leaving the default.

This query returns the total plan cache size and the number of cached plans.

SELECT TOP (5)
    CAST(SUM(size_in_bytes) / 1024.0 / 1024 AS decimal(12,2)) AS plan_cache_mb,
    COUNT_BIG(*) AS cached_plans
FROM sys.dm_exec_cached_plans
GROUP BY ();
plan_cache_mbcached_plans
186.231175

The plan cache currently holds 1,175 plans in 186 MB. That is a modest footprint in absolute terms, and the ratio (~159 KB per plan) is typical — no sign of single-use plan bloat. For a workload this small (3 user databases, mostly teaching queries), 186 MB is unremarkable. The rule of thumb becomes interesting on much larger instances: if the plan cache exceeds 500 MB and single-use plans dominate, enable optimize for ad hoc workloads to store only stubs until a plan is reused.

Unused Indexes As A Cost Signal

Indexes that are never read but are still maintained on writes are a double cost — they take disk space and they burn CPU cycles on every INSERT/UPDATE/DELETE. SQL Server tracks usage per index in sys.dm_db_index_usage_stats since instance startup, which makes unused indexes one of the easiest cost signals to capture.

Audit never-used nonclustered indexes in stoxx

After the instance has been running for long enough to have accumulated representative workload (typically weeks). It is typically triggered by storage review looking for easy wins; performance review after a schema migration; pre-migration audit. Read-only T-SQL joining sys.indexes, sys.dm_db_index_usage_stats, sys.partitions, and sys.allocation_units. Must filter out primary keys, unique constraints, and clustered indexes. Usage counters reset on instance restart, so interpret the numbers relative to instance uptime. Identify nonclustered indexes that have received zero reads (user_seeks + user_scans + user_lookups = 0) since the last instance restart, ordered by size.

Usage counters reset on every SQL Server restart

sys.dm_db_index_usage_stats is populated since the most recent instance start. An index with zero reads may simply mean the workload that uses it has not run yet. Always check sqlserver_start_time in sys.dm_os_sys_info and interpret usage counters against it — a zero-read index after 6 hours of uptime is not a dropping candidate; one with zero reads after 6 weeks of uptime typically is.

[!success] Require at least one full business cycle of uptime before dropping indexes

On OLTP estates, wait two weeks minimum. On monthly-batch estates, wait two months. Cross-check candidate indexes against the query store for historical execution plans that might have used them in a window you haven’t observed.

This query returns the 10 largest nonclustered-plus-clustered indexes with the fewest reads in the stoxx database.

SELECT TOP (10)
    s.name AS schema_name,
    o.name AS table_name,
    i.name AS index_name,
    i.type_desc,
    ISNULL(us.user_seeks, 0)   AS user_seeks,
    ISNULL(us.user_scans, 0)   AS user_scans,
    ISNULL(us.user_lookups, 0) AS user_lookups,
    ISNULL(us.user_updates, 0) AS user_updates,
    CAST(SUM(au.total_pages) * 8.0 / 1024 AS decimal(12,2)) AS index_mb
FROM stoxx.sys.indexes AS i
JOIN stoxx.sys.objects AS o
    ON o.object_id = i.object_id
JOIN stoxx.sys.schemas AS s
    ON s.schema_id = o.schema_id
JOIN stoxx.sys.partitions AS p
    ON p.object_id = i.object_id AND p.index_id = i.index_id
JOIN stoxx.sys.allocation_units AS au
    ON au.container_id = p.partition_id
LEFT JOIN sys.dm_db_index_usage_stats AS us
    ON us.database_id = DB_ID('stoxx')
   AND us.object_id   = i.object_id
   AND us.index_id    = i.index_id
WHERE o.type = 'U'
  AND i.type > 0
  AND i.is_primary_key = 0
  AND i.is_unique_constraint = 0
GROUP BY s.name, o.name, i.name, i.type_desc,
         us.user_seeks, us.user_scans, us.user_lookups, us.user_updates
ORDER BY (ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0) + ISNULL(us.user_lookups, 0)) ASC,
         index_mb DESC;
schema_nametable_nameindex_nametype_descuser_seeksuser_scansuser_lookupsuser_updatesindex_mb
dbodemo_idxmaint_rowstoreCIX_demo_idxmaint_row_guidCLUSTERED0000376.88
dbodemo_idxmaint_splitsCIX_demo_idxmaint_splitsCLUSTERED000023.07
dbodemo_eurostoxx50_ohlcvCIX_demo_eurostoxx50_ohlcvCLUSTERED00007.32
dbodemo_idxmaint_columnstoreCCI_demo_idxmaint_columnstoreCLUSTERED COLUMNSTORE00002.13
silverstoxxasia50_ohlcvIX_silver_stoxxasia50_ohlcv_symbol_dateNONCLUSTERED00001.88
silverstoxxusa50_ohlcvIX_silver_stoxxusa50_ohlcv_symbol_dateNONCLUSTERED00001.70
dbodemo_idxmaint_usageIX_demo_idxmaint_usage_symbol_dateNONCLUSTERED00001.63
dbodemo_idxmaint_usageIX_demo_idxmaint_usage_categoryNONCLUSTERED00001.13
dbodemo_index_performanceCIX_demo_index_performanceCLUSTERED00000.76
silveroil20_ohlcvIX_silver_oil20_ohlcv_symbol_dateNONCLUSTERED00000.70

Every structure in this list has zero reads and zero writes since the last instance restart — which means the instance has not run enough representative workload yet, not that these indexes are unused in production. The top entry (CIX_demo_idxmaint_row_guid at 376.88 MB) is also the largest structure in the instance. If this were a real production instance with weeks of uptime, that row would be a prime drop candidate; on this container it is just a demo fixture. The cost-relevant takeaway is methodological: on a production FinOps audit, this query is where the first easy wins usually appear — large nonclustered indexes that nobody reads but every write has to update.

ColumnValueWatchMeaningImplication
user_seeks + user_scans + user_lookups0 after weeks of uptimeIndex not used for readsCandidate for drop
user_updatesHigh with 0 readsWrite amplification with no benefitStrongest drop case — both storage and CPU saved
user_updatesLow and 0 readsWatchIndex is idle in both directionsInvestigate whether it serves a rare but important query
type_descUNIQUE or primary keyN/AConstraint-enforcingNever drop

GCP Infrastructure Cost Levers

SQL Server storage behaviour drives the costs that appear inside the VM. GCP then adds another layer of choices around persistent disk type, snapshot strategy, GCS storage class, committed use discounts, and licensing model. Every lever in this section runs against a known SQL Server signal from the previous section — the persistent disk decisions depend on allocation and used-percent numbers, the snapshot decisions depend on backup cadence and recovery objectives, and the VM decisions depend on memory and CPU footprints.

Persistent Disk Choice

The persistent disk choice affects two cost lines at once: the per-GB-month storage fee and the IOPS/throughput that upstream workloads depend on. The wrong disk type either overcharges for capacity the workload never reaches or caps the workload below its SLA.

Compare persistent disk families at a glance

Before provisioning a new SQL Server VM or migrating an existing one to a different disk family. It is typically triggered by new instance request, cost review recommending a disk family change, or a workload that has outgrown its current IOPS envelope. Reference decision. The persistent-disk choice is made at disk-creation time in the GCP console or gcloud compute disks create and is immutable in type — changing disk type requires creating a new disk and copying data over. Pick the cheapest disk that meets the workload’s IOPS, throughput, and latency requirements.

Disk typeApproximate $/GB-month (us-central1)IOPS modelTypical use for SQL Server
pd-standard (HDD)~$0.040Included, seek-penalty on random I/OBackup staging only; never for data or log on a live workload
pd-balanced (SSD)~$0.100Included; scales with size up to machine-type capSmall-to-medium SQL Server workloads; Console default
pd-ssd~$0.170Included; higher IOPS ceiling than balancedHigh-IOPS OLTP, latency-sensitive workloads
pd-extreme~$0.125 + provisioned IOPSIOPS provisioned separately (min 500 GB)Very large OLTP / analytics; explicit IOPS budgeting
hyperdisk-balanced~$0.060 + billed IOPS/throughput above baseline3,000 IOPS and 140 MB/s baseline free; extras billedGoogle-recommended default for modern VM families
hyperdisk-extreme~$0.100 + all IOPS provisionedEvery IOPS billedSub-ms latency tier; premium OLTP

Prices vary by region and change periodically — always verify at the current Disk and Image Pricing page before building a cost model.

Persistent disks cannot shrink in-place

GCP persistent disks can only grow, never shrink. A disk oversized at creation time cannot be reduced without provisioning a new smaller disk, copying the data, re-attaching, and deleting the old disk. On SQL Server this is a non-trivial operation — it requires a planned outage or a snapshot-based clone-and-cutover pattern. Size conservatively at creation.

[!success] Right-size on creation, rebuild via clone-and-cutover when wrong

  • At creation: provision 1.5× the expected 12-month footprint. pd-balanced and hyperdisk-balanced grow cheaply; the extra cost is small.
  • When over-provisioned: snapshot the current disk, create a new smaller disk from the snapshot (only if the data fits), attach to a replacement VM, cut over during a maintenance window.
  • Remember that the log file can be shrunk inside SQL Server (with DBCC SHRINKFILE) before the outside-the-VM shrink — often recovers most of the waste without touching the disk.

Create a new persistent disk for a SQL Server data volume

During new SQL Server VM provisioning, disk expansion, or clone-and-cutover rebuild. It is typically triggered by new instance, migration, or disk replacement. Runs in Cloud Shell or any authenticated gcloud session. Requires the compute.disks.create IAM permission (covered by the roles/compute.instanceAdmin role). State-changing — creates a billable resource immediately. Provision a new persistent disk of a specific type, size, and zone ready to be attached to a SQL Server VM.

This command creates a 256 GB hyperdisk-balanced disk named sql-data-01 in europe-west1-b.

gcloud compute disks create sql-data-01 \
  --zone=europe-west1-b \
  --type=hyperdisk-balanced \
  --size=256GB \
  --provisioned-iops=3000 \
  --provisioned-throughput=140 \
  --labels=workload=sql-server,env=prod,owner=platform
FlagSyntaxDescription
--zone--zone=<zone>Target zone (persistent disks are zonal by default)
--region--region=<region>Regional persistent disk (replicates across two zones in the region)
--type--type=<disk-type>pd-standard, pd-balanced, pd-ssd, pd-extreme, hyperdisk-balanced, hyperdisk-extreme, hyperdisk-throughput, hyperdisk-ml
--size--size=<N>GB or <N>TBInitial size. Can grow later, cannot shrink
--provisioned-iops--provisioned-iops=<N>Hyperdisk-balanced and hyperdisk-extreme only. Baseline free; extras billed
--provisioned-throughput--provisioned-throughput=<N>MB/s. Baseline free on hyperdisk-balanced; extras billed
--source-snapshot--source-snapshot=<snapshot>Create from a snapshot instead of blank
--source-image--source-image=<image>Create from a machine image
--labels--labels=k=v,k=vResource labels for billing export / chargeback
--kms-key--kms-key=<key>Encrypt with Cloud KMS customer-managed key (CMEK) instead of Google-managed
--description--description=<text>Free-form description for the disk
--storage-pool--storage-pool=<pool>Attach to a hyperdisk storage pool (pooled IOPS/throughput billing)

Resize a persistent disk up

When SQL Server has grown into less than 20% headroom on the current disk. It is typically triggered by disk free-percent falls below threshold; scheduled growth; addition of a new database or partition. Runs in Cloud Shell or any authenticated gcloud session. Requires compute.disks.resize (in roles/compute.instanceAdmin). The resize operation is online — the VM stays up — but the filesystem inside the VM must be extended separately afterwards (resize2fs, xfs_growfs, or Extend-Volume on Windows). Grow the persistent disk without downtime so SQL Server data files can continue to auto-grow.

This command grows an existing persistent disk to 512 GB.

gcloud compute disks resize sql-data-01 \
  --zone=europe-west1-b \
  --size=512GB
FlagSyntaxDescription
--size--size=<N>GB or <N>TBNew size; must be larger than current
--zone--zone=<zone>Zone the disk lives in
--region--region=<region>Region for a regional disk

Disk resize does not grow the filesystem or SQL Server data file

The gcloud compute disks resize operation only changes the disk capacity visible to the OS. The filesystem on the disk and the SQL Server data file inside it remain at their old size until you explicitly grow them. Forgetting this step produces the confusing state of “I resized the disk but SQL Server still says it’s full.”

[!success] Grow the filesystem and the SQL Server file after every disk resize

  • Linux ext4: sudo resize2fs /dev/disk/by-id/google-sql-data-01
  • Linux XFS: sudo xfs_growfs /var/opt/mssql/data
  • Windows: Resize-Partition in PowerShell
  • SQL Server: ALTER DATABASE stoxx MODIFY FILE (NAME = stoxx, SIZE = 2GB); (or let auto-growth handle it once the volume reports the new free space)

Backups Versus Snapshots

SQL Server backups and GCP persistent disk snapshots solve different problems. Treating them as substitutes is the single most common FinOps mistake in this space — it either doubles the cost of redundant recovery paths or creates silent holes in the recovery chain.

Compare SQL Server backups and GCP disk snapshots at the mechanism level

Once per design review, when justifying the backup architecture to platform or finance. It is typically triggered by new workload under FinOps management, architectural review, or a recovery objective conversation. Reference decision. Both layers are configured outside the database — SQL Server backups via BACKUP DATABASE and Agent jobs, snapshots via gcloud compute or resource policies. Clarify which recovery operations each layer actually supports, so retention and cadence decisions are made once per workload rather than repeatedly renegotiated.

MechanismGranularityPoint-in-time recoveryRestore speedApplication consistencyTypical use
SQL Server full backupDatabaseYes (with log chain)Seconds-to-minutes per GBAlways consistentRestore a specific database
SQL Server differential backupDatabaseYes (with log chain)Fast; layered onto the last fullAlways consistentReduce restore RTO between full backups
SQL Server log backupDatabaseYes, down to the transactionSlower at many logsAlways consistentMeet RPO < 1 hour with full log chain
SQL Server WITH SNAPSHOT + METADATA_ONLY (2022+)DatabaseYes with log chainInstant snapshot, restore is a disk cloneApplication-consistent via SUSPEND_FOR_SNAPSHOT_BACKUPRapid instance-level recovery
GCE persistent disk snapshot (crash-consistent)DiskNoFast disk cloneCrash-consistent only; SQL Server may need recovery on startupRapid infrastructure recovery; not a substitute for SQL backups
GCE persistent disk snapshot (application-consistent via T-SQL suspend)DiskYes with SQL Server 2022 log chainFast disk cloneApplication-consistentBest of both worlds; Recommended

A crash-consistent disk snapshot is not a backup

A plain gcloud compute disks snapshot without coordinating with SQL Server produces a crash-consistent snapshot. When restored, SQL Server will start up and run crash recovery on the log — which usually succeeds, but is not a guarantee, and produces no validated restore point. Relying on crash-consistent snapshots as the only recovery layer is the recovery equivalent of not running CHECKDB: it works until it doesn’t, and when it doesn’t you have lost data with no trail.

[!success] Use application-consistent snapshots with SUSPEND_FOR_SNAPSHOT_BACKUP on SQL Server 2022+

  • On SQL Server 2022+: use ALTER DATABASE ... SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON to freeze write I/O before taking the GCE snapshot. Write freeze is typically 400–800 ms.
  • On SQL Server 2019 and earlier: combine the disk snapshot with a SQL Server BACKUP DATABASE + log chain — the disk snapshot becomes a fast rehydration layer, the SQL backups provide the validated restore chain.
  • Never rely on a single layer — always have a SQL-aware chain and a disk-level rehydration path, with retention on each aligned to different recovery objectives.

Create a snapshot schedule (resource policy) for the SQL Server data disk

Once per new SQL Server VM, or when the recovery cadence needs to change. It is typically triggered by new instance needs automated snapshots, or existing cadence is wrong (too frequent for cost, or too infrequent for RPO). Runs as a gcloud command in Cloud Shell or any authenticated session. Requires compute.resourcePolicies.create. The policy exists in a region; it does nothing until attached to a specific disk. Define a reusable snapshot cadence that can be applied to one or many disks.

This command creates a daily snapshot schedule retained for 14 days, running at 03:00 in the europe-west1 region.

gcloud compute resource-policies create snapshot-schedule sql-daily \
  --region=europe-west1 \
  --max-retention-days=14 \
  --start-time=03:00 \
  --daily-schedule \
  --storage-location=europe-west1 \
  --snapshot-labels=workload=sql-server,tier=prod
FlagSyntaxDescription
--region--region=<region>Region the policy belongs to
--max-retention-days--max-retention-days=<N>Automatic snapshot retention window
--start-time--start-time=HH:MMUTC start time of the schedule
--daily-schedule(flag)Snapshot once per day at --start-time
--hourly-schedule--hourly-schedule=<hours>Snapshot every N hours
--weekly-schedule--weekly-schedule=<days>Snapshot on specific days of the week
--storage-location--storage-location=<region>Regional or multi-regional snapshot storage location
--snapshot-labels--snapshot-labels=k=v,...Labels applied to every snapshot the policy produces
--on-source-disk-deleteapply-retention-policy / keep-auto-snapshotsRetention behaviour when the source disk is deleted
--guest-flush(flag)Application-consistent snapshot on Windows via VSS
--description--description=<text>Free-form description

Attach the snapshot schedule to the data disk

Immediately after creating the resource policy and before the first expected snapshot window. It is typically triggered by policy created but not yet effective; new disk that should inherit the existing schedule. Runs as a gcloud compute disks add-resource-policies command. Requires compute.disks.addResourcePolicies. The disk can have at most one snapshot schedule attached at a time. Make the snapshot schedule take effect on a specific disk so automated snapshots begin on the next scheduled window.

This command attaches the sql-daily policy to the sql-data-01 persistent disk.

gcloud compute disks add-resource-policies sql-data-01 \
  --zone=europe-west1-b \
  --resource-policies=sql-daily

This command verifies the attachment by listing the resource policies currently applied to the disk.

gcloud compute disks describe sql-data-01 \
  --zone=europe-west1-b \
  --format="value(resourcePolicies)"
FlagSyntaxDescription
--zone--zone=<zone>Zone of the target disk
--region--region=<region>For regional disks
--resource-policies--resource-policies=<name>Comma-separated list of policy names
--format--format=<format>Output formatter (json, yaml, value(...), table(...))

Take an application-consistent snapshot on SQL Server 2022+

During the regular snapshot window; in an automation script triggered by Cloud Scheduler or Cloud Workflows; as a manual pre-change safety snapshot. It is typically triggered by scheduled snapshot window, pre-change safety, or ad-hoc recovery point creation. Three-step orchestration: (1) T-SQL ALTER DATABASE to suspend writes, (2) gcloud to capture the snapshot, (3) T-SQL BACKUP DATABASE ... WITH SNAPSHOT, METADATA_ONLY to emit the backup set record and auto-resume writes. Requires ALTER on the database and compute.disks.createSnapshot. Write-freeze window is typically 400–800 ms, well under the 1-second threshold most applications tolerate. Produce a snapshot that can be rehydrated into a SQL Server instance with a valid recovery chain, without stalling reads and with a tiny write-freeze window.

A long write freeze blocks every writer on the database

SUSPEND_FOR_SNAPSHOT_BACKUP freezes write I/O for the duration of the suspend + snapshot + metadata-only sequence. If any step of that sequence hangs — a stuck snapshot API call, a network blip — writes remain frozen until the timeout kicks in. The typical window is under a second, but the failure mode is a database that looks hung to applications.

[!success] Wrap the sequence with a watchdog timeout and a manual thaw

  • Script the three steps with a 30-second overall timeout.
  • On failure, explicitly call ALTER DATABASE <db> SET SUSPEND_FOR_SNAPSHOT_BACKUP = OFF to thaw writes.
  • Monitor sys.dm_server_suspend_status from a separate session to observe the suspension state live.

This T-SQL statement suspends write I/O on the stoxx database.

ALTER DATABASE stoxx
SET SUSPEND_FOR_SNAPSHOT_BACKUP = ON;

This shell command captures a GCE persistent disk snapshot while writes are frozen.

gcloud compute disks snapshot sql-data-01 \
  --zone=europe-west1-b \
  --snapshot-names=sql-data-01-$(date +%Y%m%d-%H%M%S) \
  --storage-location=europe-west1 \
  --labels=workload=sql-server,type=app-consistent

This T-SQL statement emits the metadata-only backup record and auto-resumes writes.

BACKUP DATABASE stoxx
TO DISK = '/var/opt/mssql/backup/stoxx_snapshot.bkm'
WITH METADATA_ONLY, FORMAT;

This T-SQL query verifies that no database remains suspended after the sequence completes.

SELECT db_name, is_write_io_frozen, is_diff_map_cleared, suspend_time_ms
FROM sys.dm_server_suspend_status;
Flag / parameterApplies toDescription
SUSPEND_FOR_SNAPSHOT_BACKUP = ONALTER DATABASEFreezes write I/O on the target database
SUSPEND_FOR_SNAPSHOT_BACKUP = OFFALTER DATABASEManually thaws writes (emergency path only)
WITH METADATA_ONLYBACKUP DATABASEWrites only the backup metadata record; no data copy
WITH FORMATBACKUP DATABASEOverwrites an existing backup file
WITH COPY_ONLYBACKUP DATABASEDoes not clear the differential bitmap
--snapshot-namesgcloud compute disks snapshotExplicit snapshot name with timestamp
--storage-locationgcloud compute disks snapshotRegional or multi-regional snapshot storage
--labelsgcloud compute disks snapshotBilling labels on the snapshot itself
--guest-flushgcloud compute disks snapshotVSS coordination on Windows (not needed with T-SQL suspend)

GCS Backup Bucket Strategy

Persistent disks hold the live database and in-place snapshots. The SQL Server full / differential / log backup chain belongs in GCS, where lifecycle rules, storage class transitions, and object retention lock can be used to control long-term cost without compromising recoverability.

Choose a GCS storage class for each tier of backup

Once per backup bucket, during bucket creation or policy review. It is typically triggered by new backup repository, retention review, or GCS bill exceeding expectations. Reference decision. Storage class is set per object, and can be changed over time by lifecycle policies. Match each tier of backup to the cheapest storage class that still meets its access and retention requirements.

Class$/GB-month (US, approx)Minimum storage durationRetrieval feeTypical SQL Server use
Standard$0.020None$0Current week of full + differential + log backups; anything actively restored
Nearline$0.01030 days$0.01/GBPrevious month of full backups and log chain
Coldline$0.00490 days$0.02/GBQuarterly archive and long-horizon DR
Archive$0.0022365 days$0.05/GBRegulatory long-term retention, > 1 year

For a workload that produces a 100 MB compressed full backup plus log chain per day, storing 7 days on Standard + 30 days on Nearline + 90 days on Coldline + 1 year on Archive costs a fraction of a dollar per month — the cost is dominated by operations and egress, not storage.

Minimum storage duration is charged even for early-deleted objects

Deleting a Nearline object 5 days after creation still incurs the 30-day minimum storage charge. Same logic for Coldline (90 days) and Archive (365 days). If you accidentally set a lifecycle rule that transitions + deletes fresh objects into Archive, you pay one year of Archive storage for every object, regardless of actual retention.

[!success] Align lifecycle rule ages with minimum storage durations

  • Transition to Nearline at day 30, not day 7 (keeps the minimum cost low).
  • Transition to Coldline at day 90, not day 30.
  • Transition to Archive at day 365, only if you need > 1 year retention.

Create a backup bucket with versioning and public-access prevention

Once, during initial setup of the SQL Server backup repository on GCS. It is typically triggered by new SQL Server workload under FinOps management, or migration from an on-prem backup target. Runs as a gcloud storage buckets create command. Requires storage.buckets.create (in roles/storage.admin). State-changing. Provision a GCS bucket ready to receive SQL Server backups with sensible defaults for safety and FinOps.

This command creates a regional bucket with uniform bucket-level access, object versioning, and public-access prevention.

gcloud storage buckets create gs://sql-backups-prod \
  --location=europe-west1 \
  --default-storage-class=STANDARD \
  --uniform-bucket-level-access \
  --public-access-prevention \
  --enable-autoclass
FlagSyntaxDescription
--location--location=<region> or <multi-region>europe-west1, EU, US, etc. Choose the cheapest region near the VM
--default-storage-class--default-storage-class=STANDARD/NEARLINE/COLDLINE/ARCHIVEDefault class for new objects
--uniform-bucket-level-access(flag)Enforces bucket-level IAM; disables object ACLs
--public-access-prevention(flag)Blocks any public IAM binding on the bucket
--enable-autoclass(flag)Automatically moves objects between classes based on access patterns
--retention-period--retention-period=<duration>Sets a minimum retention period on objects in the bucket (Bucket Lock)
--versioning(flag)Enables object versioning
--pap--pap=enforcedAlternative syntax for public-access prevention
--lifecycle-file--lifecycle-file=<path>Apply a lifecycle policy at creation
--labels--labels=k=v,k=vBilling labels

Apply an object lifecycle policy to the backup bucket

After the bucket is created and the retention policy has been agreed with application and compliance owners. It is typically triggered by new bucket, retention policy change, or cost review flagging an oversized bucket. Runs as gcloud storage buckets update --lifecycle-file=<file>. Requires storage.buckets.update. The JSON file defines the rules declaratively; GCS then enforces them on its own schedule. Automate storage-class transitions and object expiration so backup age drives cost automatically without an operator in the loop.

This is the lifecycle.json file that transitions objects through Standard → Nearline → Coldline → Archive and finally deletes at 3 years.

{
  "lifecycle": {
    "rule": [
      {
        "action": { "type": "SetStorageClass", "storageClass": "NEARLINE" },
        "condition": { "age": 30, "matchesStorageClass": ["STANDARD"] }
      },
      {
        "action": { "type": "SetStorageClass", "storageClass": "COLDLINE" },
        "condition": { "age": 90, "matchesStorageClass": ["NEARLINE"] }
      },
      {
        "action": { "type": "SetStorageClass", "storageClass": "ARCHIVE" },
        "condition": { "age": 365, "matchesStorageClass": ["COLDLINE"] }
      },
      {
        "action": { "type": "Delete" },
        "condition": { "age": 1095 }
      }
    ]
  }
}

This command applies the lifecycle policy file to the bucket.

gcloud storage buckets update gs://sql-backups-prod \
  --lifecycle-file=lifecycle.json

This command verifies the policy was applied correctly.

gcloud storage buckets describe gs://sql-backups-prod \
  --format="json(lifecycle_config)"
JSON fieldTypeMeaning
action.typeSetStorageClass | Delete | AbortIncompleteMultipartUploadWhat to do when the condition is met
action.storageClassstringTarget class for SetStorageClass
condition.ageint (days)Object age in days
condition.createdBeforedateAbsolute cutoff date
condition.isLiveboolCurrent version vs. non-current
condition.matchesStorageClassstring[]Apply only to objects currently in these classes
condition.numNewerVersionsintApply only when N newer versions exist
condition.daysSinceNoncurrentTimeintDays since the object became non-current
condition.daysSinceCustomTimeintDays since the custom object time

Enable bucket lock for compliance retention

Only when the workload genuinely requires WORM (write once, read many) retention for compliance — typically SEC 17a-4, FINRA, or CFTC. It is typically triggered by regulatory requirement, audit finding, or data protection policy. Runs as gcloud storage buckets update with a retention period. Bucket Lock is a two-step operation — first set the retention period, then lock it. Once locked, the retention period can never be reduced or removed. Requires storage.buckets.update and storage.buckets.lockRetentionPolicy. Prevent any backup object — by any identity, including project admins — from being deleted or overwritten before the retention period expires.

Locking a retention policy is irreversible and permanent

After lock-retention-policy succeeds, the bucket’s minimum retention period cannot be reduced, and the policy cannot be removed. The bucket itself cannot be deleted until every object has aged past the retention threshold. Lifecycle-rule deletes will be silently skipped until the retention period expires on each object. Test the configuration on a disposable bucket first, and only run the lock after the period has been validated by compliance.

[!success] Dry-run on a disposable bucket, confirm with legal, then lock

  • Create a test bucket gs://sql-backups-lock-test-<date> with the intended retention period.
  • Verify behaviour by attempting to delete an object — it must fail with an objectRetention error.
  • Confirm with legal/compliance that the retention period matches regulation.
  • Apply to the real bucket only after sign-off.

This command sets a 7-year retention period on the bucket (retention period is in seconds — 7 years ≈ 220,898,664 seconds).

gcloud storage buckets update gs://sql-backups-prod \
  --retention-period=220898664s

This command permanently locks the retention period — after this completes, the retention period cannot be reduced.

gcloud storage buckets update gs://sql-backups-prod \
  --lock-retention-period

This command verifies the bucket’s retention policy and lock status.

gcloud storage buckets describe gs://sql-backups-prod \
  --format="json(retention_policy)"
FieldValuesMeaning
retentionPolicy.retentionPeriodsecondsMinimum duration objects must exist before deletion
retentionPolicy.isLockedtrue / falseWhether the policy can still be modified
retentionPolicy.effectiveTimeISO timestampWhen the policy became effective

Compute Commitment Choices

The biggest rate-optimization lever on GCE is committed use discounts. CUDs are not right for every workload — getting them wrong creates a bill for capacity you cannot use — but the pricing math is simple enough that the decision can be made from the same observed memory and CPU numbers the previous section captured.

Compare compute purchase models

Before provisioning a new SQL Server VM or before renewing a CUD. It is typically triggered by new instance, CUD renewal cycle, workload stability review. Reference decision. CUDs are irrevocable once purchased — you pay the monthly fee for the full term whether you use the capacity or not. Choose the purchase model that matches the workload’s stability profile and the business’s tolerance for committing capital in advance.

Purchase modelTypical discountCommitment termReversible?When to use
On-demand0NoneYesNew workload, unproven utilization, short-lived
Sustained Use Discount (SUD)Auto ~0–30%None, automaticN/AAny long-running VM — applies without action
1-year resource CUD~37%12 months, irrevocableNoProduction workload with > 6 months history at stable size
3-year resource CUD~55%36 months, irrevocableNoLong-life production workload, memory-optimized VMs (up to ~70% on memory-optimized)
1-year spend CUD (flex)~28%12 months, irrevocableNoMultiple workloads of changing shape under one billing account
3-year spend CUD (flex)~46%36 months, irrevocableNoSame as 1-year flex but with bigger commitment
Spot VM60–91%NoneYesRebuildable non-production, batch, dev/test

Never run production SQL Server on a Spot VM as the primary node

Spot VMs can be preempted with 30 seconds’ notice. SQL Server cannot guarantee graceful shutdown and recovery under a preemption window — even with SIGTERM, the write-ahead log may not flush cleanly, and certain in-flight operations (checkpoint, long rebuild) cannot be interrupted safely. Using Spot VMs as the primary node for a production database is a recipe for periodic corruption or data loss.

[!success] Spot VMs are fine for replicas and non-production only

  • Non-production dev / test VMs that can be rebuilt from a snapshot: Spot is acceptable.
  • Secondary replicas in an Always On AG where the primary is on standard or CUD capacity: Spot can cut cost with minimal risk.
  • Primary production nodes: never Spot, always on standard capacity (ideally CUD).

Calculate CUD break-even

Before signing a 1-year or 3-year resource CUD. It is typically triggered by utilization has been stable for long enough that a commitment is plausible; budget cycle. Pencil-and-paper math against observed utilization. Compare the CUD monthly cost to the on-demand cost at the VM’s observed utilization, accounting for SUDs. Decide whether the CUD actually saves money at the workload’s real utilization pattern, not at the theoretical maximum.

UtilizationCUD vs on-demand math (approximate)
100% usage1-yr CUD saves ~37% flat vs on-demand; 3-yr CUD saves ~55% flat
75% usageCUD is “on” for 100% of hours (you pay the commitment) but only 75% of hours produce value — effective saving ≈ CUD discount × 0.75 ≈ 28%/41%
50% usageEffective saving drops to ~19%/28%. Break-even with SUD on N1 is around this point
< 50% usageSUD + on-demand is usually cheaper than 1-yr CUD. 3-yr CUD only makes sense above ~60% sustained

This reasoning applies per resource. For a mixed estate, flex (spend-based) CUDs give more room to reshape workloads without stranding the commitment, at the cost of a lower discount percentage.

Request machine-type right-sizing recommendations from GCP Recommender

After the VM has been running for at least 8 days with representative workload. It is typically triggered by cost review, quarterly right-sizing cadence, or budget variance alert. Runs as gcloud recommender recommendations list. Requires recommender.computeInstanceMachineTypeRecommendations.get. Read-only — emits recommendations, does not apply them. The recommender needs at least 8 days of metrics, uses 60-second averages, and only emits a recommendation when the estimated saving is ≥ $10/month. Surface the specific VMs where observed utilization is below the machine type and the recommender thinks a smaller type would save money.

This command lists machine-type recommendations for a specific zone and project.

gcloud recommender recommendations list \
  --project=data-platform-prod \
  --location=europe-west1-b \
  --recommender=google.compute.instance.MachineTypeRecommender \
  --format="table(name,primaryImpact.costProjection.cost.units,description)"

This command lists the same recommendations in JSON with the full proposed change.

gcloud recommender recommendations list \
  --project=data-platform-prod \
  --location=europe-west1-b \
  --recommender=google.compute.instance.MachineTypeRecommender \
  --format=json

This command marks a recommendation as claimed so it does not keep appearing in reports.

gcloud recommender recommendations mark-claimed \
  RECOMMENDATION_ID \
  --project=data-platform-prod \
  --location=europe-west1-b \
  --recommender=google.compute.instance.MachineTypeRecommender \
  --etag=ETAG
FlagSyntaxDescription
--project--project=<id>Target project
--location--location=<zone>Zone where the VMs live
--recommender--recommender=<id>google.compute.instance.MachineTypeRecommender for right-sizing
--format--format=<format>Output format
--filter--filter=<expr>Filter by state, priority, or target resource
--page-size--page-size=<N>Paging

Recommender uses 60-second averages — short spikes are invisible

GCP Recommender samples CPU and memory at 60-second granularity. A SQL Server workload that spikes for 20 seconds during query compilation or checkpoint is invisible to the recommender and can be mis-sized downward. For bursty OLTP, never apply recommendations blindly.

[!success] Cross-check Recommender output against SQL Server memory DMVs

  • Use the sys.dm_os_process_memory audit from the previous section as the source of truth for memory.
  • Cross-check CPU against sys.dm_os_wait_stats and a SOS_SCHEDULER_YIELD investigation before downsizing.
  • Install the Ops Agent on the VM to get memory metrics into the recommender — without it, recommendations only consider CPU.

Stop non-production VMs on a schedule

Once per non-production workload that has idle windows — typically nights and weekends. It is typically triggered by cost review flagging non-prod VMs as a significant line item; SRE policy enforcement. Two operations — a Cloud Scheduler job that emits an event, and a Cloud Run service (or Cloud Function) that stops the VM on the event. Requires cloudscheduler.jobs.create and compute.instances.stop. A simpler pattern uses gcloud compute instances stop directly in a cron on a management VM. Eliminate the cost of non-production VMs during idle windows without forcing engineers to stop them manually.

This command stops a VM on demand.

gcloud compute instances stop sql-dev-01 \
  --zone=europe-west1-b

This command creates a Cloud Scheduler job that stops the VM every night at 20:00 local time.

gcloud scheduler jobs create http sql-dev-01-stop \
  --schedule="0 20 * * 1-5" \
  --time-zone="Europe/Brussels" \
  --uri="https://compute.googleapis.com/compute/v1/projects/data-platform-dev/zones/europe-west1-b/instances/sql-dev-01/stop" \
  --http-method=POST \
  --oauth-service-account-email=scheduler@data-platform-dev.iam.gserviceaccount.com

This command creates the complementary start job that wakes the VM up on weekday mornings.

gcloud scheduler jobs create http sql-dev-01-start \
  --schedule="0 7 * * 1-5" \
  --time-zone="Europe/Brussels" \
  --uri="https://compute.googleapis.com/compute/v1/projects/data-platform-dev/zones/europe-west1-b/instances/sql-dev-01/start" \
  --http-method=POST \
  --oauth-service-account-email=scheduler@data-platform-dev.iam.gserviceaccount.com
FlagSyntaxDescription
--schedule--schedule="<cron>"Unix cron expression
--time-zone--time-zone=<tz>IANA time zone
--uri--uri=<url>Target HTTP endpoint
--http-method--http-method=POST/GETHTTP method
--oauth-service-account-email--oauth-service-account-email=<sa>Service account for OAuth auth
--message-body--message-body=<json>Optional request body
--headers--headers="k=v,k=v"Optional request headers

SQL Server Licensing On GCP

Licensing is the largest irreversible cost decision for SQL Server workloads. On GCP there are three primary paths — BYOL with License Mobility, license-included GCE images, and Cloud SQL for SQL Server — each with different cost characteristics and different failure modes.

Compare licensing models for SQL Server on GCE

Before provisioning a new SQL Server VM or before a licensing renewal decision. It is typically triggered by new workload, SA renewal, migration planning, or cost review flagging licensing as the dominant line item. Reference decision. Cannot be changed on a running VM — BYOL and license-included GCE images use different disk images entirely. Pick the licensing path that minimises total cost while meeting edition, version, and compliance requirements.

ModelHow it worksCost characteristicEdition choices
BYOL with License MobilityBring existing SQL Server licenses with active Software Assurance (SA) to multi-tenant GCE VMsNo GCP licensing fee; you pay for SA and the license itself through MicrosoftAny edition you own
License-included GCE imageGCE provides a disk image pre-installed with SQL Server; GCP charges per vCPU-hour on top of VM costMinimum 4 cores billed regardless of vCPU count; no upfront Microsoft costWeb / Standard / Enterprise / Enterprise Core (SQL Server 2019 or 2022)
BYOL on sole-tenant node (no SA)Outsourcing Software Management Rights on dedicated physical nodes, for licenses without SAYou pay the full sole-tenant node cost regardless of utilizationPre-Oct 2019 versions only; SQL Server 2022 requires SA
Cloud SQL for SQL ServerFully managed service; GCP handles patching, backups, replication, HAPer vCPU/hour + per-GB-month storage; includes licenseStandard / Enterprise / Enterprise Plus

License Mobility requires active SA and a 10-day verification window

BYOL with License Mobility is contingent on active Software Assurance. You must complete Microsoft’s license verification within 10 days of deployment, and the license cannot move to another GCP region for 90 days after deployment. Miss the verification window and Microsoft treats the deployment as unlicensed — a compliance finding, not just a cost issue.

[!success] Verify SA status and complete the Microsoft portal step before deployment

  • Confirm active SA on the licenses you intend to move.
  • Prepare the License Verification form before deployment day.
  • Submit within the first week, not day 9.
  • For SQL Server 2022: SA is mandatory for any virtual deployment — there is no BYOL-without-SA path.

Audit which edition is actually required by the workload

During any cost review considering a licensing change or downgrade. It is typically triggered by cost review, migration planning, or a proposal to consolidate workloads on cheaper editions. Read-only T-SQL against sys.dm_db_persisted_sku_features (database-scoped), plus a review of instance-level features. Combine with the persisted SKU feature audit from the previous section. Produce a concrete list of features the workload uses that are gated on specific editions, so the edition choice is driven by actual requirements rather than “we picked Enterprise five years ago.”.

FeatureStandardEnterpriseCost-relevance
Row, page, columnstore compressionYesYesNone — available on all editions since 2016 SP1
Backup compressionYesYesNone — available on Standard since 2008
TDEYesYesNone — available on Standard since 2016 (not on Web/Express)
Data maskingYesYesNone
Row-level securityYesYesNone
In-Memory OLTPYes (32 GB/DB cap)Yes (OS max)Big if > 32 GB
Always On AG (full)No (Basic AG only)YesBig — AG topology choice
Online index rebuildNoYesMedium — Standard rebuilds are blocking
Resource GovernorNoYesMedium — Standard cannot throttle backup CPU
Max buffer pool128 GBOS maxVery big on large working sets
Max sockets/cores4 sockets / 24 coresOS maxVery big on large VMs

The hard cost drivers on the Standard vs Enterprise decision are the 128 GB buffer-pool cap, the 24-core ceiling, and the lack of Resource Governor. The features that used to be Enterprise-only before 2016 SP1 (compression, partitioning, columnstore, In-Memory OLTP) are all on Standard now, so a workload that fits inside the memory/core caps has little reason to pay the Enterprise premium.


Monitoring, Chargeback, And Governance

Optimization lands once; drift begins the day after. The Operate phase of FinOps is everything that keeps cost optimized over time — billing export, labels, budget alerts, policy, and scheduled audits. Without this layer, every cost gain from the previous section regresses within a few quarters as engineers spin up new VMs, forget to label them, and let retention policies decay.


flowchart LR
    A[GCP Billing] -->|daily export| B[BigQuery billing dataset]
    C[Resource labels] --> B
    B --> D[Cost dashboards]
    B --> E[Scheduled queries]
    E --> F[Budget alerts]
    E --> G[Anomaly detection]
    D --> H[FinOps review cadence]
    F --> H
    G --> H
    H --> I[Optimize actions]
    I --> A

Tag And Label Every Resource

Labels are the backbone of chargeback and showback. A cost line item without a team label cannot be attributed, cannot be challenged, and cannot be optimized.

Apply labels at resource creation time

On every gcloud compute instances create, gcloud compute disks create, and gcloud storage buckets create command. It is typically triggered by any new resource provisioning. Labels are specified via --labels on create commands, or added later via update commands. Labels are key-value pairs; both keys and values are lowercase alphanumeric-plus-dash-plus-underscore. Maximum 64 labels per resource. Tag every resource with enough metadata to attribute cost, enforce policy, and filter the billing export by team, environment, and workload.

This command creates a VM with the standard label set.

gcloud compute instances create sql-prod-01 \
  --zone=europe-west1-b \
  --machine-type=n2-standard-4 \
  --image-family=debian-12 \
  --image-project=debian-cloud \
  --labels=team=data-platform,env=prod,workload=sql-server,owner=platform,costcenter=cc-1234

This command adds or updates labels on an existing VM.

gcloud compute instances add-labels sql-prod-01 \
  --zone=europe-west1-b \
  --labels=backup-tier=gold,compliance=finra

This command lists all VMs missing the mandatory team label.

gcloud compute instances list \
  --filter="-labels.team:*" \
  --format="table(name,zone,status)"
Label keyPurposeExample values
teamOwning teamdata-platform, esg-research, portfolio-engineering
envEnvironmentprod, stage, dev, test
workloadWhat runs on the resourcesql-server, postgres, airflow, bigquery-etl
ownerOn-call contact or roleplatform, appname-oncall
costcenterFinance cost centercc-1234
backup-tierRetention tierbronze, silver, gold
complianceRegulatory contextfinra, sec-17a4, gdpr, none
created-byTool that created the resourceterraform, gcloud-cli, console

Labels apply from the moment they are set — historical cost is not relabeled

Labels do not retroactively tag cost in the billing export. If a VM runs for six months without a team label and you add the label today, only the cost from today forward carries the label. Historical chargeback requires either best-effort reconciliation from resource inventories or accepting an untagged-cost bucket.

[!success] Enforce labels at creation time with org policy or Terraform

  • GCP Organization Policy: constraints/compute.requireResourceLabelsForCreate requires a label set on every new Compute resource.
  • Terraform: use a module-level default_labels variable so every resource inherits the mandatory set.
  • Budget alerts: configure per-label filters so new labels automatically get their own cost view.

Export Billing To BigQuery

Billing export is the one step that turns GCP cost data from monthly PDF into queryable, timely, filterable tables. Without it, every cost question becomes an email thread instead of a SQL query.

Enable billing export to BigQuery

Once per billing account, during initial FinOps setup. It is typically triggered by new FinOps practice, new billing account, or migration from a legacy export. Configured in the Cloud Console under Billing → Billing export → BigQuery export. Requires Billing Account Administrator permission. The export is an organizational-level data flow from GCP Billing into a BigQuery dataset you own, refreshed several times per day. Stream daily cost data into BigQuery so FinOps dashboards and alerts can query it.

There are two export tables. The standard export is lighter-weight; the detailed export adds resource-level granularity.

TableName patternGranularityLatencyTypical use
Standard usage costgcp_billing_export_v1_<BILLING_ACCOUNT_ID>Service / SKU / project / labelMultiple times per dayDashboards, summaries, variance reports
Detailed usage costgcp_billing_export_resource_v1_<BILLING_ACCOUNT_ID>Resource-level (VM / disk / bucket)Multiple times per dayDrill-down, chargeback, resource-level audit
Pricingcloud_pricing_exportSKU pricesOn changeFuture-cost modelling

Query monthly spend by team label

At the start of each FinOps cadence (weekly or monthly), or on demand when investigating a cost spike. It is typically triggered by scheduled review, variance alert, or ad-hoc question. Runs as a BigQuery SQL query against the standard export table. Requires bigquery.jobs.create and bigquery.tables.getData on the billing dataset. Read-only. Aggregate monthly cost by team label so every team sees its own line items and can challenge or explain them.

FieldSourceMeaning
invoice.monthinvoice.monthYYYYMM integer
label.valuelabels[key='team'].valueTeam name
costcostPre-credit cost in billing-account currency
creditscredits[]Sustained-use, committed-use, free-tier, promotional

This query aggregates monthly cost by team for the last 3 months, net of credits.

SELECT
    invoice.month,
    team_label.value AS team,
    ROUND(SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)), 2) AS net_cost_eur
FROM `billing_project.billing_dataset.gcp_billing_export_v1_XXXX`,
UNNEST(labels) AS team_label
WHERE team_label.key = 'team'
  AND invoice.month >= FORMAT_DATE('%Y%m', DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH))
GROUP BY invoice.month, team
ORDER BY invoice.month DESC, net_cost_eur DESC;
invoice.monthteamnet_cost_eur
202603data-platform4821.73
202603esg-research1942.50
202603portfolio-engineering1108.04
202602data-platform4612.88
202602esg-research1889.12

Sample output shape — actual values depend on your billing export. The key mechanical detail is UNNEST(labels) — labels are stored as a repeated key/value struct, so unnest-filtering on key = 'team' is how you aggregate by a specific label without row explosion.

Table-level labels on BigQuery tables do not appear in billing export

BigQuery resource-level labels (VMs, clusters, jobs) appear in the labels array on the billing export. BigQuery table- and view-level labels do not — they live in the BigQuery metadata layer only. For BigQuery cost attribution, label the jobs (via --labels on bq query) and the datasets, not the tables.

[!success] Label at the job level for BigQuery chargeback

  • bq query --labels=team=data-platform,env=prod --use_legacy_sql=false 'SELECT ...'
  • Airflow, Dataflow, and Dataproc all accept job-level labels that propagate into the billing export.

Query monthly spend for SQL Server workloads specifically

Weekly or monthly during the FinOps review, or when investigating a SQL Server-specific cost change. It is typically triggered by cost review, variance alert, or right-sizing evaluation. BigQuery SQL query filtering on the workload label. Read-only. Produce a SQL Server-scoped cost summary so the FinOps review has a single number for the whole estate.

This query aggregates last-30-days cost for all resources labelled workload=sql-server, broken down by service.

SELECT
    service.description AS service,
    sku.description AS sku,
    ROUND(SUM(cost), 2) AS cost_eur,
    SUM(usage.amount) AS usage_amount,
    usage.unit AS usage_unit
FROM `billing_project.billing_dataset.gcp_billing_export_v1_XXXX`,
UNNEST(labels) AS workload_label
WHERE workload_label.key = 'workload'
  AND workload_label.value = 'sql-server'
  AND usage_start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY service, sku, usage_unit
ORDER BY cost_eur DESC
LIMIT 20;
serviceskucost_eurusage_amountusage_unit
Compute EngineN2 Instance Core running in EMEA183.42720hour
Compute EngineStorage PD Balanced45.60512gibibyte month
Compute EngineStorage PD Snapshot12.85180gibibyte month
Cloud StorageStandard Storage (europe-west1)6.10300gibibyte month
Compute EngineLicensing Fee for SQL Server Standard on VM (CPU cost)142.80720hour

Sample output shape. The value of this query is that it orders every SQL Server cost line item by absolute euros, so the FinOps conversation starts with the biggest number rather than with anecdotes.

Budgets And Alerts

Budgets are the guardrail that catches drift before the monthly invoice arrives. A budget without an alert is a spreadsheet; a budget with alerts is a signalling system.

Create a budget scoped to a project and label

Once per project during FinOps setup, or when a new cost-owning label is introduced. It is typically triggered by new project, new team, or annual budget review. Runs as gcloud billing budgets create or via the Cloud Console. Requires billing.budgets.create on the billing account. The budget itself does not block spending; it only triggers notifications. Define a monthly budget and threshold alerts so any team exceeding its envelope is notified before month-end.

This command creates a €5,000 monthly budget for the data-platform team with 50%, 90%, and 100% threshold alerts.

gcloud billing budgets create \
  --billing-account=0X0X0X-0X0X0X-0X0X0X \
  --display-name="data-platform-monthly" \
  --budget-amount=5000EUR \
  --threshold-rule=percent=0.5,basis=current-spend \
  --threshold-rule=percent=0.9,basis=current-spend \
  --threshold-rule=percent=1.0,basis=current-spend \
  --notifications-rule-pubsub-topic=projects/data-platform-prod/topics/finops-alerts \
  --filter-projects=projects/data-platform-prod \
  --filter-labels=team=data-platform
FlagSyntaxDescription
--billing-account--billing-account=<id>Billing account the budget belongs to
--display-name--display-name=<text>Human-readable name
--budget-amount--budget-amount=<N><CURRENCY> or --last-period-amountFixed amount or “same as last month”
--threshold-rule--threshold-rule=percent=<0-1>,basis=current-spend|forecasted-spendAlert threshold; can repeat
--notifications-rule-pubsub-topic--notifications-rule-pubsub-topic=<topic>Pub/Sub topic for programmatic alerts
--notifications-rule-monitoring-notification-channels--notifications-rule-monitoring-notification-channels=<channels>Cloud Monitoring notification channels
--filter-projects--filter-projects=projects/<id>Scope to specific projects
--filter-labels--filter-labels=<k>=<v>Scope to a label
--filter-services--filter-services=<service>Scope to specific GCP services
--credit-types-treatmentinclude-all-credits/exclude-all-credits/include-specified-creditsHow to treat credits

A budget alert is not spending enforcement

Budget alerts fire as notifications via Pub/Sub or email, but nothing stops the spending. A project that exceeds 100% of its budget keeps running until someone manually acts on the alert. Treating budgets as a cap leads to runaway invoices.

[!success] Wire the Pub/Sub topic to an automation that can act

  • At 50%, notify the team via Slack.
  • At 90%, notify the FinOps lead and page the on-call.
  • At 100%, trigger a Cloud Function that either stops non-prod VMs or escalates to an incident response channel.

Detect cost anomalies with scheduled BigQuery queries

Daily, via a BigQuery scheduled query. It is typically triggered by continuous monitoring; designed to fire before the monthly invoice cycle. Runs as a BigQuery scheduled query against the billing export, comparing yesterday’s cost to the preceding 7-day average per team or per service. Surface cost anomalies quickly — the day after an unexpected VM spins up, not a month later.

This query returns teams whose yesterday cost exceeded the 7-day average by more than 50%.

WITH daily AS (
  SELECT
    DATE(usage_start_time) AS usage_date,
    team_label.value AS team,
    SUM(cost) AS daily_cost_eur
  FROM `billing_project.billing_dataset.gcp_billing_export_v1_XXXX`,
  UNNEST(labels) AS team_label
  WHERE team_label.key = 'team'
    AND usage_start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY usage_date, team
),
rolling AS (
  SELECT
    usage_date,
    team,
    daily_cost_eur,
    AVG(daily_cost_eur) OVER (
      PARTITION BY team
      ORDER BY usage_date
      ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
    ) AS rolling_7d_avg
  FROM daily
)
SELECT
  usage_date,
  team,
  ROUND(daily_cost_eur, 2) AS daily_cost_eur,
  ROUND(rolling_7d_avg, 2) AS rolling_7d_avg,
  ROUND(100.0 * (daily_cost_eur - rolling_7d_avg) / NULLIF(rolling_7d_avg, 0), 1) AS delta_percent
FROM rolling
WHERE usage_date = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
  AND rolling_7d_avg > 0
  AND daily_cost_eur > 1.5 * rolling_7d_avg
ORDER BY delta_percent DESC;

Sample output — every row is a team whose yesterday spend was more than 50% above the rolling 7-day average, sorted by variance percent. Wire this to Cloud Monitoring to page the team on any non-empty result.

Build A Cost Dashboard For SQL Server Workloads

The point of a dashboard is not to display data — it is to make the next FinOps decision obvious without writing a new query every time. A good SQL Server cost dashboard has five panels: monthly trend, cost by service, cost by team, cost by resource, and active alerts.

PanelSource queryRefreshTypical decision
Monthly trend (last 12 months)Monthly sum from billing export, filtered to workload=sql-serverDailyBudget variance
Cost by serviceMonthly breakdown by service.descriptionDailyWhich GCP service is the biggest line item
Cost by teamMonthly breakdown by team labelDailyChargeback conversation
Top 20 resourcesResource-level breakdown from detailed exportDailyRight-sizing targets
Active budget alertsBudget status from Pub/Sub subscriptionReal-timeImmediate escalation

Looker Studio is the usual tool on GCP because it connects natively to BigQuery. For teams already on Grafana, the BigQuery data source plugin works, and Grafana’s alerting is more flexible than Looker Studio’s.


Practical Recommendations

The signals from the live stoxx capture and the GCP cost levers resolve into a concrete action list, ordered by reversibility and expected impact. Every recommendation maps back to a specific query result or cost mechanic from the earlier sections.


flowchart TD
    Signal[Cost signal observed]
    Signal --> Q1{Is it reversible?}
    Q1 -->|Yes| Q2{Is the fix a config flip?}
    Q1 -->|No| Q3{Is utilization history credible?}
    Q2 -->|Yes| Win[Quick win: apply now]
    Q2 -->|No| Plan[Structural change: plan window]
    Q3 -->|Yes| Plan
    Q3 -->|No| Wait[Operate phase: collect data first]
    Win --> Review[Review in next FinOps cadence]
    Plan --> Review
    Wait --> Review
    style Signal fill:#292e42,stroke:#565f89,color:#c0caf5
    style Win fill:#1f3b2d,stroke:#73d13d,color:#c0caf5
    style Plan fill:#292e42,stroke:#565f89,color:#c0caf5
    style Wait fill:#4a1f24,stroke:#db4b4b,color:#c0caf5

Quick Wins

These are the reversible, config-level changes that should land within the first week of any FinOps engagement on this instance.

Quick-win checklist

  • Enable backup compression default = 1. Observed full-backup ratio on stoxx is 6.26–6.48:1; the lever is worth ~500 MB per full backup. Run the sp_configure plus verify queries from the Backup Storage Footprint section. No downtime.
  • Cap max server memory (MB) to a realistic value. Currently at the int32 max (uncapped). On the current 4.2 GB footprint, 6144 MB is a safe cap. Prevents OOM-kill on the Linux container and bounds memory-cost conversations.
  • Raise cost threshold for parallelism to 50. Currently at the 1998 default of 5. Avoids trivial queries going parallel, reduces CPU burn, no downtime.
  • Right-size the stoxx log file. Currently 1 GB allocated, 15 MB in use. Shrink to 256 MB during a quiet window via DBCC SHRINKFILE (stoxx_log, 256) and re-size once trimmed.
  • Cap max degree of parallelism to 4 or 8 if the workload is mostly OLTP. Matches the 8-core tempdb setup and aligns with the typical guidance of ≤ 8 on mixed loads.
  • Label every GCE resource with team, env, workload, owner, costcenter. Use add-labels on existing resources and wire Terraform to require them on creation.
  • Enable billing export to BigQuery if not already set up. Single console action; day-one enabler for every dashboard and alert that follows.
  • Create a budget with 50 / 90 / 100% alerts on the primary SQL Server project. Wire the Pub/Sub topic to Slack.

Structural Changes

These changes require a maintenance window or a rebuild operation, and should be planned into the next FinOps cycle rather than applied ad-hoc.

Structural-change checklist

  • Apply page compression to the largest user tables. sp_estimate_data_compression_savings on silver.eurostoxx50_ohlcv projected ~40% savings; the same ratio on dbo.demo_idxmaint_rowstore (377 MB) would recover ~150 MB. Schedule the rebuild during a quiet window on Standard edition, or use ONLINE = ON on Enterprise.
  • Rebuild the persistent disk onto a smaller size. Current disk: 1006.85 GB total, 921.70 GB free (91.54% waste). Target: 256 GB pd-balanced or hyperdisk-balanced. Requires a snapshot + new disk + clone-and-cutover window. Expected monthly saving at pd-balanced rates is ~$75/instance.
  • Create and attach a snapshot resource policy so every data disk inherits a daily, 14-day-retention snapshot schedule.
  • Migrate backup jobs to a labelled GCS bucket with a lifecycle policy. Apply the Standard → Nearline → Coldline → Archive → Delete rule chain; align transition ages with minimum storage durations (30 / 90 / 365 days).
  • Switch to application-consistent snapshots using SUSPEND_FOR_SNAPSHOT_BACKUP (SQL Server 2022+). Replaces crash-consistent snapshots with a validated recovery chain and 400–800 ms write-freeze window.
  • Audit sys.dm_db_persisted_sku_features across every user database to confirm no hidden Enterprise-only features block a Standard downgrade. On stoxx, the only reported feature is ColumnStoreIndex, which has been Standard-compatible since 2016 SP1.
  • Evaluate BYOL vs license-included images on the renewal cycle. BYOL with SA is usually cheaper for long-running workloads; license-included is simpler for short-lived or experimental instances.
  • Set up a Cloud Scheduler stop/start job for every non-production SQL Server VM. Nightly stop, morning start, weekdays only — typically cuts non-prod VM cost by 60–70%.
  • Decide the Committed Use Discount coverage level after the VM has been stable for ≥ 6 months. Start with a 1-year resource CUD at the observed p50 size; avoid overcommitting to peak.

Continuous Practices

These are the Operate-phase habits that prevent regression. They do not save money on day one — they prevent savings from decaying.

Continuous-practice checklist

  • Monthly FinOps cadence with a shared dashboard. Same chart, same queries, every time. Variance discussion is a standing agenda item.
  • Weekly cost anomaly query (the rolling 7-day check from the Monitoring section) as a scheduled BigQuery job firing into Slack.
  • Quarterly right-sizing review using GCP Recommender output cross-checked with sys.dm_os_process_memory and wait stats.
  • Quarterly compression audit to find tables newly eligible for row or page compression as workloads evolve.
  • Annual CUD renewal review before the commitment window expires. Compare actual utilization to the commitment shape; adjust up/down.
  • Pre-change safety snapshot before every SQL Server upgrade, patching cycle, or major schema change. Use application-consistent snapshots on 2022+.
  • Retention audit of the GCS backup bucket every six months. Lifecycle rules decay silently when object naming conventions change.
  • Label drift audit every quarter — list resources missing mandatory labels, re-tag them or force the creator to do so.
  • Cost-review documentation: write every significant cost change back into this note (or its sibling playbook) so the next FinOps reviewer inherits the rationale, not just the outcome.

What The Current Stoxx Signals Imply

These are the conclusions that follow directly from the live queries in the earlier sections, specific to this instance.

SignalObservationRecommended action
backup compression default0 at instance level, but explicit WITH COMPRESSION is already giving 6.26:1 on full backupsFlip default to 1 to cover new jobs automatically
max server memory (MB)2147483647 (uncapped)Cap to a realistic value — 6144 MB given the current 4.2 GB footprint
stoxx log file1032 MB allocated, 14.80 MB in use (1.43%)Shrink to 256 MB and re-grow only if sustained use justifies it
stoxx data file712 MB allocated, 597.69 MB used (83.94%)No action; healthy headroom
stoxx VLFs44 VLFs averaging 23.45 MB, 1 activeHealthy; no action
Persistent disk free1006.85 GB total, 921.70 GB free (91.54%)Rebuild onto 256 GB disk via clone-and-cutover
Data compression stateZero user-table structures compressed (except always-on columnstore)Start with page compression on the largest structures
sp_estimate_data_compression_savings on silver.eurostoxx50_ohlcv39.5% clustered, 41.5% nonclusteredApply page compression during maintenance window
sys.dm_db_persisted_sku_featuresOnly ColumnStoreIndexNo Enterprise-only blockers for a Standard downgrade
Backup history10 recent backups; full backups compressing to 15.4% of uncompressed sizeChain is healthy; focus on retention lifecycle
tempdb layout8 × 8 MB data files + 1 × 8 MB log, 64 MB growthFile count is correct; initial size is small — pre-allocate if workload grows
cost threshold for parallelism5 (default)Raise to 50
max degree of parallelism0 (unlimited)Cap to 4 or 8

Further Reading

This section is intentionally a pointer. FinOps on SQL Server straddles three official documentation sets and several authoritative community sources, and the decisions change with every major release and pricing update — always verify against the source.

Microsoft Docs

Google Cloud Docs

FinOps Foundation

Peer Vault Notes