Tuning SQL Server on Linux: What Microsoft recommends and what actually helps
PREEMPTIVE_OS_FLUSHFILEBUFFERS is at the top of your wait statistics. On Windows that would be a rare sight. On Linux it is usually normal operation: after writing, SQL Server explicitly asks the operating system to put the data on the disk, and waits for it. In our lab this happened after every single commit.
This page goes through what determines performance and stable operation of SQL Server on Linux: the kernel and system settings Microsoft recommends, flushing on writes, the limits for memory and CPU, where the database files live, and monitoring. What we could measure, we measured on SQL Server 2017, 2019, 2022 and 2025 in Linux containers. What we could not measure is stated as a Microsoft recommendation, with its source.
In 60 seconds
- Microsoft recommends a set of kernel settings for SQL Server hosts, bundled in the TuneD profile
mssql: Transparent Huge Pages on,swappiness1, NUMA balancing off, large read-ahead,noatime, XFS. Many standard installations differ from it, and so does our lab server. The host check on this page shows your deviations. - Writing costs one flush per commit on Linux. That is the safe default. It only gets faster with storage that supports FUA and the matching settings.
writethrough = 0can cost you data. - Limit memory in three levels: container or host limit,
memory.memorylimitmbfor the whole SQL Server process,max server memoryfor the buffer pool. Leave 10 to 20 percent headroom in between. SQL Server 2017 and 2019 without a setting were killed by the kernel in our 4 GB container, without a single line in the errorlog. - SQL Server does not see a CPU quota.
PROCESS AFFINITYon as many CPUs as the quota allows was 16 to 20 percent faster in our lab and almost removed the throttling. Trace flag 8002 added nothing measurable. - Container images only configure themselves in part: From 2019 they create tempdb files by CPU count (eight at most), but MAXDOP stays 0 and the cost threshold stays 5. Both need adjusting after the first start.
- Where the files live in the container did not matter for performance: write layer, bind mount and volume were on par. The write layer is still ruled out, for operational reasons.
1 · Microsoft's list for the host
Microsoft describes the recommended settings on the page "Performance best practices and configuration guidelines for SQL Server on Linux" and bundles them in the TuneD profile mssql (Red Hat ships it, on Ubuntu you create it by hand). All of these values belong to the host: in a container you can read them but not change them.
| Setting | Recommendation (Microsoft) | Linux default (Ubuntu 24.04, our lab) | Purpose |
|---|---|---|---|
| Transparent Huge Pages | always (several instances: madvise) | madvise | large memory pages for the buffer pool |
vm.swappiness | 1 | 60 | do not swap out the buffer pool |
vm.dirty_ratio / dirty_background_ratio | 80 / 3 | 20 / 10 | the kernel's write buffer |
vm.max_map_count | 1,600,000 (at least 262,144) | 1,048,576 | many memory regions of the process |
kernel.numa_balancing | 0 with several NUMA nodes | 1 (2 nodes) | SQL Server manages NUMA itself |
| TuneD profile | mssql | none | bundles the values above |
| Read-ahead | 4096 sectors per device | 128 KB | larger reads |
| Mount option | noatime | relatime | no access-time writes |
| File system | XFS (ext4 supported) | ext4 | |
| Swap | present, as a safety net | none | protection from the OOM killer |
We did not measure how much each deviation costs: our lab server is shared with other measurements, and changes to the kernel would have affected them. The recommendations apply to servers where SQL Server is the main load; for a host with several applications or instances, Microsoft itself names exceptions (for example madvise instead of always).
There are differences even between Microsoft's page and the profile Red Hat ships: the upstream profile sets the dirty values in bytes and keeps CPU scheduler values active that Microsoft leaves out for newer kernels. If you rebuild TuneD on Ubuntu, pick one version and document it.
Host check
The following script reads the values and compares them with the recommendation. It changes nothing.
#!/usr/bin/env bash
# PSG Stack · SQL Server on Linux and Docker · Host check
# Compares the Linux settings of a SQL Server host with Microsoft's recommendations
# (Learn: "Performance best practices: Storage, kernel, CPU, and network", as of 05/2026).
#
# !!! WARNING — please read before running !!!
# - Read-only. The script changes no setting and writes no file.
# - Usage: bash host-check-en.sh [data directory] [log directory]
# Default: /var/opt/mssql/data for both. Without root, single values may be missing (shown as "?").
# - Kernel values apply to the whole host. Inside a container the script shows the host's values;
# they can only be changed on the host, not in the container.
# - "check" means: different from Microsoft's recommendation. Whether the difference costs you anything
# is not something the script can tell; the recommendations apply to dedicated SQL Server hosts.
# - Tested in the PSG lab on Ubuntu 24.04 (host) and in the containers of SQL Server 2017 to 2025
# (13 Nov 2026); not tested on Red Hat or SUSE.
set -u
DATA=${1:-/var/opt/mssql/data}
LOG=${2:-$DATA}
rd() { cat "$1" 2>/dev/null | tr -d '\n' || true; }
row() { printf '%-34s %-28s %-30s %s\n' "$1" "$2" "$3" "$4"; }
cmp() { if [ "$1" = "$2" ]; then echo ok; elif [ -z "$1" ] || [ "$1" = "?" ]; then echo "?"; else echo check; fi; }
echo "Host check SQL Server on Linux · $(date '+%Y-%m-%d %H:%M') · $(hostname)"
. /etc/os-release 2>/dev/null; echo "System: ${PRETTY_NAME:-?} · kernel $(uname -r)"
if [ -f /.dockerenv ] || grep -qa docker /proc/1/cgroup 2>/dev/null; then echo "Note: running in a container; kernel values are the host's."; fi
echo
row "Setting" "Current" "Recommended (Microsoft)" "Rating"
row "-------" "-------" "-----------------------" "------"
thp=$(rd /sys/kernel/mm/transparent_hugepage/enabled | sed -n 's/.*\[\(.*\)\].*/\1/p'); thp=${thp:-?}
row "Transparent Huge Pages" "$thp" "always (several instances: madvise)" "$(cmp "$thp" always)"
sw=$(rd /proc/sys/vm/swappiness); sw=${sw:-?}
row "vm.swappiness" "$sw" "1" "$(cmp "$sw" 1)"
mm=$(rd /proc/sys/vm/max_map_count); mm=${mm:-?}
if [ "$mm" != "?" ] && [ "$mm" -ge 262144 ] 2>/dev/null; then b=ok; [ "$mm" -lt 1600000 ] && b="ok (profile: 1600000)"; else b=$(cmp "$mm" 1600000); fi
row "vm.max_map_count" "$mm" "1600000 (at least 262144)" "$b"
dr=$(rd /proc/sys/vm/dirty_ratio); dbr=$(rd /proc/sys/vm/dirty_background_ratio)
row "vm.dirty_ratio / background" "${dr:-?} / ${dbr:-?}" "80 / 3 (TuneD profile mssql)" "$( [ "${dr:-}" = 80 ] && [ "${dbr:-}" = 3 ] && echo ok || echo check )"
numa=$(ls -d /sys/devices/system/node/node* 2>/dev/null | wc -l)
nb=$(rd /proc/sys/kernel/numa_balancing); nb=${nb:-?}
if [ "$numa" -gt 1 ]; then b=$(cmp "$nb" 0); else b="ok (1 NUMA node)"; fi
row "kernel.numa_balancing" "$nb ($numa NUMA nodes)" "0 with several NUMA nodes" "$b"
if command -v tuned-adm >/dev/null 2>&1; then t=$(tuned-adm active 2>/dev/null | sed -n 's/.*: //p'); else t="no TuneD"; fi
row "TuneD profile" "${t:-?}" "mssql" "$( [ "${t:-}" = mssql ] && echo ok || echo check )"
gov=$(rd /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor); gov=${gov:-"n/a (VM?)"}
row "CPU governor" "$gov" "performance" "$( [ "$gov" = performance ] && echo ok || { [ "${gov#n/a}" != "$gov" ] && echo info || echo check; } )"
sw_on=$(awk 'NR>1' /proc/swaps 2>/dev/null | wc -l)
row "Swap configured" "$( [ "$sw_on" -gt 0 ] && echo yes || echo no )" "yes (safety net, not capacity)" "$( [ "$sw_on" -gt 0 ] && echo ok || echo check )"
for dir in $(printf "%s\n%s\n" "$DATA" "$LOG" | awk '!s[$0]++'); do
[ -d "$dir" ] || { row "Directory $dir" "missing" "" "?"; continue; }
read -r dev fs opts < <(awk -v p="$(df -P "$dir" 2>/dev/null | awk 'NR==2{print $6}')" '$2==p{print $1, $3, $4}' /proc/mounts | tail -1)
if [ "${fs:-}" = overlay ]; then
row "File system $dir" "overlay" "volume or bind mount" "check (container writable layer)"; continue; fi
row "File system $dir" "${fs:-?}" "xfs (ext4 supported)" "$( [ "${fs:-}" = xfs ] && echo ok || { [ "${fs:-}" = ext4 ] && echo ok || echo check; } )"
row " Mount option atime" "$(echo "${opts:-?}" | grep -o 'noatime\|relatime\|strictatime' | head -1)" "noatime" "$(echo "${opts:-}" | grep -q noatime && echo ok || echo check)"
blk=$(basename "$(readlink -f "${dev:-x}" 2>/dev/null)" | sed 's/[0-9]*$//; s/p$//')
if [ -n "$blk" ] && [ -d "/sys/block/$blk/queue" ]; then
ra=$(rd /sys/block/$blk/queue/read_ahead_kb); sch=$(rd /sys/block/$blk/queue/scheduler | sed -n 's/.*\[\(.*\)\].*/\1/p')
fua=$(rd /sys/block/$blk/queue/fua); wc=$(rd /sys/block/$blk/queue/write_cache)
row " Read-ahead ($blk)" "${ra:-?} KB" "4096 (blockdev --setra, sectors)" "info"
row " I/O scheduler ($blk)" "${sch:-?}" "no value given (multiqueue)" "info"
row " FUA / write cache ($blk)" "${fua:-?} / ${wc:-?}" "only with FUA=1 trace flag 3979" "info"
else
row " Block device" "not determinable" "" "?"
fi
done
CONF=/var/opt/mssql/mssql.conf
echo
if [ -r "$CONF" ]; then
echo "mssql.conf ($CONF), entries relevant for performance:"
grep -E '^\s*(writethrough|alternatewritethrough|memorylimitmb|traceflag[0-9]*)\s*=' "$CONF" | sed 's/^/ /' || echo " (none; defaults apply)"
else
echo "mssql.conf not readable or not present (defaults apply; in a container possibly MSSQL_* variables)."
fi
echo "Linux default: forced flush (trace flag 3982), control.writethrough = 1, control.alternatewritethrough = 1."
echo "Never set writethrough = 0 unless it is proven that the storage is O_DIRECT safe."Sample output
From the lab: our host with Ubuntu 24.04, data and log on separate virtual disks. Almost every kernel setting differs from Microsoft's list; that is the normal case for a standard installation.
Host check SQL Server on Linux · 2026-11-13 10:33 · psg-sql-lab01
System: Ubuntu 24.04.5 LTS · kernel 6.8.0-147-generic
Setting Current Recommended (Microsoft) Rating
------- ------- ----------------------- ------
Transparent Huge Pages madvise always (several instances: madvise) check
vm.swappiness 60 1 check
vm.max_map_count 1048576 1600000 (at least 262144) ok (profile: 1600000)
vm.dirty_ratio / background 20 / 10 80 / 3 (TuneD profile mssql) check
kernel.numa_balancing 1 (2 NUMA nodes) 0 with several NUMA nodes check
TuneD profile no TuneD mssql check
CPU governor n/a (VM?) performance info
Swap configured no yes (safety net, not capacity) check
File system /srv/sql/data ext4 xfs (ext4 supported) ok
Mount option atime relatime noatime check
Read-ahead (sdc) 128 KB 4096 (blockdev --setra, sectors) info
I/O scheduler (sdc) none no value given (multiqueue) info
FUA / write cache (sdc) 0 / write back only with FUA=1 trace flag 3979 info
File system /srv/sql/log ext4 xfs (ext4 supported) ok
Mount option atime relatime noatime check
Read-ahead (sdd) 128 KB 4096 (blockdev --setra, sectors) info
I/O scheduler (sdd) none no value given (multiqueue) info
FUA / write cache (sdd) 0 / write back only with FUA=1 trace flag 3979 info
mssql.conf not readable or not present (defaults apply; in a container possibly MSSQL_* variables).
Linux default: forced flush (trace flag 3982), control.writethrough = 1, control.alternatewritethrough = 1.
Never set writethrough = 0 unless it is proven that the storage is O_DIRECT safe.2 · Writing and flushing
A commit is only complete when the log record is safely on the disk. On Windows, SQL Server requests this with a flag on each write. On Linux it depends on whether the disk supports "Forced Unit Access" (FUA), that is, whether it can make individual writes durable, bypassing the cache.
| Variant | Settings | When |
|---|---|---|
| Forced flush (default) | trace flag 3982 (internal), control.writethrough = 1, control.alternatewritethrough = 1 | always, as long as the storage is not proven to support FUA |
| FUA writes | trace flag 3979, writethrough = 1, alternatewritethrough = 0 | only with FUA-capable storage (XFS from kernel 4.18, ext4 from 5.6), not on overlayfs |
| unsafe | writethrough = 0 | never, unless it is proven that the storage is O_DIRECT-safe |
By default, SQL Server calls fdatasync after writing; that is the wait type PREEMPTIVE_OS_FLUSHFILEBUFFERS. In our lab (data and log on separate virtual disks, ext4, disks without FUA) this is what many small transactions looked like:
| Commits/s (one session / four sessions) | 2017 | 2019 | 2022 | 2025 |
|---|---|---|---|---|
| Default (flush per commit, 6.4 ms on average) | 141 / 542 | 141 / 463 | 140 / 427 | 140 / 380 |
Trace flag 3979, alternatewritethrough = 0 (no flush) | 293 / 777 | 293 / 931 | 291 / 662 | 290 / 894 |
All four versions behave the same: 20,000 commits, about 20,000 calls of fdatasync. On SQL Server 2025, a commit took about 7.1 ms by default, of which 6.4 ms was flush; without flush about 3.4 ms.
The second row is not a recommendation: our disks report no FUA, and Microsoft names trace flag 3979 only for storage that supports it. We did not check whether the kernel really flushes the disk cache for every write in this case. The measurement shows what the flush per commit costs, not that you should switch it off.
Two observations for operations:
DBCC TRACESTATUSdoes not show the default: trace flag 3982 appears with status 0 even though forced flush is active. If 3979 is set, it shows with status 1 and in the errorlog as the startup parameter-T 3979.- SQL Server does not see
control.writethroughandcontrol.alternatewritethrough; they are in/var/opt/mssql/mssql.confin the[control]section, set withmssql-conf set control.alternatewritethrough 0. If the section is missing, the defaults apply.
High WRITELOG times have the same causes on Linux as on Windows: a slow log disk or very many very small transactions. On Linux, every commit you save by grouping rows into one transaction also saves a flush.
3 · Memory in three levels
| Level | Limits | Set with |
|---|---|---|
| Host or container | everything | VM size or docker run --memory |
memory.memorylimitmb | the whole SQL Server process (buffer pool, SQLPAL, Agent, full-text) | mssql-conf set memory.memorylimitmb, in the container MSSQL_MEMORY_LIMIT_MB |
max server memory | the buffer pool only | sp_configure |
Microsoft recommends 10 to 20 percent headroom between the container limit and memory.memorylimitmb, and max server memory below that. Without a setting, SQL Server takes 80 percent of the smaller of host memory and container limit, but only from 2022 CU20 also under cgroup v2; 2017 and 2019 do not see the limit of a container there (Part 3).
We reproduced what happens with a 4 GB container (--memory=4g, no swap) and a load that reads and groups a 4.7 GB table several times:
| Version | no setting | memory.memorylimitmb 20% below the limit (3,277 MB) |
|---|---|---|
| 2017 | sees 64 GB, target 60 GB: killed by the kernel | load finished, peak 2.8 GB |
| 2019 | sees 64 GB, target 61 GB: killed by the kernel | load finished, peak 3.2 GB |
| 2022 | sees 3.2 GB (80% of the limit): load finished, peak 3.3 GB | load finished, peak 3.3 GB |
| 2025 | sees 3.2 GB (80% of the limit): load finished, peak 3.3 GB | load finished, peak 3.4 GB |
memory.memorylimitmb 20 percent below the limit, the load completed on all versions tested.SQL Server 2022 and 2025 even survived memory.memorylimitmb equal to the container limit with this load (peak 3.87 and 3.76 of 4 GB). That does not mean it is safe without headroom: we did not stress Agent, CLR or large memory grants for queries.
An OOM kill leaves no line in the SQL Server errorlog. In our lab the errorlog ended with the normal startup messages, then nothing. The evidence is in two other places: in the kernel log (dmesg or journalctl -k: oom-kill:constraint=CONSTRAINT_MEMCG … oom_memcg=/system.slice/docker-<id>.scope) and, with Docker, in the container state (docker inspect: OOMKilled: true, exit code 137). If you only monitor the errorlog, you see a restart without a reason.
A bug where the process used more memory than memory.memorylimitmb allows is fixed in SQL Server 2019 CU27 and 2022 CU14 (KB5042369).
4 · CPU and NUMA
SQL Server does not see a CPU quota (docker run --cpus, Kubernetes CPU limits): it creates a scheduler for every CPU of the host and plans parallelism accordingly (Part 3). As a countermeasure, Microsoft names ALTER SERVER CONFIGURATION SET PROCESS AFFINITY on as many CPUs as the quota allows, under cgroup v2 together with trace flag 8002 (soft affinity: the schedulers may switch CPUs). We measured this with a parallel aggregation in a container with --cpus=4 on a host with 24 CPUs:
| Runtime (median of 5) and time throttled by the kernel | 2019 | 2022 | 2025 |
|---|---|---|---|
| Default: 24 schedulers, parallelism 24 | 775 ms, 51 s | 873 ms, 60 s | 991 ms, 58 s |
PROCESS AFFINITY CPU = 0 TO 3: 4 schedulers | 648 ms, 0 s | 697 ms, 0 s | 791 ms, 0 s |
| Affinity and trace flag 8002 | 637 ms, 0 s | 707 ms, 0 s | 776 ms, 0 s |
| trace flag 8002 only | 790 ms, 49 s | 874 ms, 61 s | 984 ms, 62 s |
PROCESS AFFINITY matched to the quota removes the throttling and makes the query 16 to 20 percent faster. Trace flag 8002 changes little.Without affinity, SQL Server plans 24 parallel threads on four permitted CPUs; the kernel then keeps stalling the container for the rest of its time slice. With affinity on four CPUs, parallelism matches the quota, the throttling disappears, and the query gets 16 to 20 percent faster. Trace flag 8002 (soft affinity) changed the median by 2 percent at most, with strong outliers in both directions on 2022 and 2025. In a smaller preliminary run with 800,000 rows, affinity alone was slower; that was due to the small amount of data.
Our recommendation for containers with a CPU quota: set PROCESS AFFINITY to match the quota; use trace flag 8002 only if Microsoft's note applies to your environment and you measure it. Pinned CPUs (--cpuset-cpus) are simpler; SQL Server 2022 and 2025 recognize them, while 2017 and 2019 still see all CPUs of the host.
NUMA: On hosts with several NUMA nodes, Microsoft recommends turning off the kernel's automatic NUMA balancing (kernel.numa_balancing = 0), because SQL Server controls its own memory placement. We did not measure this.
Defaults in the container: This is how the images configure themselves at first start on our host with 24 CPUs:
| 2017 | 2019 | 2022 | 2025 | |
|---|---|---|---|---|
tempdb data files, no limit or with --cpus=6 | 1 | 8 | 8 | 8 |
tempdb data files, with 6 pinned CPUs (--cpuset-cpus) | 1 | 8 | 6 | 6 |
| MAXDOP / cost threshold | 0 / 5 | 0 / 5 | 0 / 5 | 0 / 5 |
So from 2019 on, the images create tempdb like a package installation does: one file per CPU, eight at most. What counts is what SQL Server sees as a CPU; it does not see a quota, and pinned CPUs only from 2022. MAXDOP and cost threshold, on the other hand, are at the old default in all versions. Set both after the first start, as described in our tempdb guide and as on Windows.
A trap for scripts: in the container, SQL Server reports itself ready ("SQL Server is now ready for client connections") before tempdb is finished. The errorlog first shows “The tempdb database has 1 data file(s).” and one to two seconds later the final number. If you check or start load right after the first login, you see an intermediate state or get error 904 (“cannot be autostarted during server shutdown or start”). In start scripts, wait for “Recovery is complete” in the errorlog, not only for a successful login.
5 · Where the database files live
In a container, database files can live in the write layer of the container (overlayfs), on a bind mount of a host directory, or in a named Docker volume. Docker recommends volumes for write-heavy loads; Microsoft explicitly requires a mount, not overlayfs, for the FUA path. We measured the same load in four variants:
| SQL Server 2025 | Commits/s (1 / 4 sessions) | Bulk insert 1 million rows | Backup |
|---|---|---|---|
| Container write layer (overlayfs) | 141 / 422 | 3.21 s | 3.23 s |
| Bind mount, same disk | 141 / 442 | 3.21 s | 3.28 s |
| Named volume | 141 / 424 | 3.23 s | 3.35 s |
| Bind mounts, data and log on separate disks | 141 / 472 | 3.16 s | 3.43 s |
SQL Server 2022 showed the same picture (141 commits/s in all variants, bulk 2.99 to 3.17 s); the values with four sessions scattered between 334 and 482 without a pattern. On our host, the location therefore did not matter for performance. Still, database files do not belong in the write layer: they disappear with the container, and updates require recreating the container (Part 3). Separate disks for data and log remain sensible if the disks themselves are separate; with virtual disks on the same SSD as here, it brings little.
File system: Microsoft recommends XFS for data and log, ext4 is supported; plus noatime. We did not measure XFS against ext4, and we did not find a published measurement.
6 · Monitoring on Linux
Inside the database, you monitor on Linux as you do on Windows: Query Store, DMVs, Extended Events, the Performance Dashboard in SSMS. The view of the operating system is different:
- No Performance Monitor. The SQL Server counters are available through
sys.dm_os_performance_counters; you have to compute rates and percentages from the raw values yourself. - Operating system tools:
top/htop,vmstat,iostat,sar(packagesysstat). Red Hat ships a plug-in for SQL Server with Performance Co-Pilot (pmdamssql), which collects DMV values as metrics. - Wait types that look different on Linux:
PREEMPTIVE_OS_FLUSHFILEBUFFERSis often near the top and is not a finding on its own (section 2). - The Management Data Warehouse (Data Collector) in SSMS is not supported against Linux instances.
Operations check: How is my instance doing?
The following query rates the points on this page for one instance, one row per check. What the instance sees of its platform and container limits is shown by Query 2 in Part 3; Query 1 in Part 1 shows which features it uses that are missing on Linux.
Query: Operations check
/* PSG Stack · SQL Server on Linux and Docker · Query 4 "Operations check" Checks settings and waits that matter on Linux for performance and stable operation: flush trace flags, memory limits and memory pressure, CPUs and schedulers, MAXDOP, tempdb, waits when writing the transaction log. One row per check with a rating. !!! WARNING — please read before running !!! - Read-only. The query changes no data and no setting. - Permissions: VIEW SERVER STATE (from 2022, VIEW SERVER PERFORMANCE STATE covers most checks); without VIEW ANY DEFINITION the tempdb check reports "not checked". - MAXDOP 1: every part runs serially and uses no additional cores. - Waits count since the last start or since the statistics were last cleared. - Rating "check" means: look closer, do not change automatically. Guidelines follow Microsoft Learn (Linux performance pages, as of 05/2026) and the PSG lab; they do not replace an analysis. - SQL Server 2017 and later. Tested in the PSG lab on 2017, 2019, 2022 and 2025 in Linux containers, as sa and without sysadmin (13 Nov 2026); not tested on Windows (Linux checks show n/a there).*/SET NOCOUNT ON; DECLARE @r TABLE (nr int, area nvarchar(20), [check] nvarchar(80), value nvarchar(200), guideline nvarchar(200), rating nvarchar(10), note nvarchar(400));DECLARE @linux bit = 0, @cpu int, @sched int, @mem_mb bigint, @target_mb bigint, @model nvarchar(60), @maxmem bigint, @maxdop int, @ctp int, @container nvarchar(60) = N'n/a'; IF OBJECT_ID(N'sys.dm_os_host_info') IS NOT NULL SELECT @linux = CASE WHEN host_platform = N'Linux' THEN 1 ELSE 0 END FROM sys.dm_os_host_info OPTION (MAXDOP 1); SELECT @cpu = cpu_count, @mem_mb = physical_memory_kb / 1024, @target_mb = committed_target_kb / 1024, @model = sql_memory_model_descFROM sys.dm_os_sys_info OPTION (MAXDOP 1);IF EXISTS (SELECT 1 FROM sys.all_columns WHERE object_id = OBJECT_ID(N'sys.dm_os_sys_info') AND name = N'container_type_desc') EXEC sys.sp_executesql N'SELECT @c = container_type_desc FROM sys.dm_os_sys_info OPTION (MAXDOP 1);', N'@c nvarchar(60) OUTPUT', @c = @container OUTPUT;SELECT @sched = COUNT(*) FROM sys.dm_os_schedulers WHERE status = N'VISIBLE ONLINE' OPTION (MAXDOP 1);SELECT @maxmem = CONVERT(bigint, value_in_use) FROM sys.configurations WHERE name = N'max server memory (MB)' OPTION (MAXDOP 1);SELECT @maxdop = CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'max degree of parallelism' OPTION (MAXDOP 1);SELECT @ctp = CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'cost threshold for parallelism' OPTION (MAXDOP 1); /* 1 · Writing and flushing (Linux only) */DECLARE @tf TABLE (TraceFlag int, Status int, Global int, Session int);BEGIN TRY INSERT @tf EXEC (N'DBCC TRACESTATUS(3979, 3982, 8002, -1) WITH NO_INFOMSGS');END TRYBEGIN CATCHEND CATCH;INSERT @r SELECT 1, N'Flush', N'Flush mode (trace flag 3979)', CASE WHEN @linux = 0 THEN N'n/a (Windows)' WHEN NOT EXISTS (SELECT 1 FROM @tf) THEN N'not readable' WHEN EXISTS (SELECT 1 FROM @tf WHERE TraceFlag = 3979 AND Global = 1) THEN N'FUA writes (3979 on)' ELSE N'forced flush (default, 3979 off)' END, N'forced flush, unless the storage provably supports FUA', N'info', N'Forced flush is the Linux default, even though DBCC TRACESTATUS does not show 3982 as active (PSG lab). 3979 only with FUA-capable storage and alternatewritethrough = 0.';INSERT @r SELECT 2, N'Flush', N'writethrough / alternatewritethrough', N'in mssql.conf (SQL Server does not show them)', N'1 / 1 (default) or 1 / 0 with trace flag 3979', N'info', N'Check with cat /var/opt/mssql/mssql.conf, section [control]; if it is missing, the defaults apply. writethrough = 0 can lose data.'; /* 2 · Waits when writing (since start) */DECLARE @total bigint, @flush bigint, @wl_ms bigint, @wl_n bigint;SELECT @total = SUM(wait_time_ms) FROM sys.dm_os_wait_statsWHERE wait_type NOT LIKE N'SLEEP%' AND wait_type NOT LIKE N'BROKER%' AND wait_type NOT LIKE N'XE%' AND wait_type NOT LIKE N'%QUEUE%' AND wait_type NOT LIKE N'HADR%' AND wait_type NOT LIKE N'SQLTRACE%' AND wait_type NOT LIKE N'CLR%' AND wait_type NOT LIKE N'FT_%' AND wait_type NOT LIKE N'%IDLE%' AND wait_type NOT IN (N'WAITFOR', N'CHECKPOINT_QUEUE', N'DIRTY_PAGE_POLL', N'DISPATCHER_QUEUE_SEMAPHORE', N'LOGMGR_QUEUE', N'REQUEST_FOR_DEADLOCK_SEARCH', N'SP_SERVER_DIAGNOSTICS_SLEEP', N'QDS_ASYNC_QUEUE', N'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', N'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP', N'PWAIT_EXTENSIBILITY_CLEANUP_TASK', N'SOS_WORK_DISPATCHER', N'PVS_PREALLOCATE', N'PREEMPTIVE_XE_DISPATCHER', N'PREEMPTIVE_OS_FLUSHFILEBUFFERS_IDLE')OPTION (MAXDOP 1);SELECT @flush = SUM(CASE WHEN wait_type = N'PREEMPTIVE_OS_FLUSHFILEBUFFERS' THEN wait_time_ms ELSE 0 END), @wl_ms = SUM(CASE WHEN wait_type = N'WRITELOG' THEN wait_time_ms ELSE 0 END), @wl_n = SUM(CASE WHEN wait_type = N'WRITELOG' THEN waiting_tasks_count ELSE 0 END)FROM sys.dm_os_wait_stats OPTION (MAXDOP 1);INSERT @r SELECT 3, N'Flush', N'Share of PREEMPTIVE_OS_FLUSHFILEBUFFERS in waits', CASE WHEN ISNULL(@total, 0) = 0 THEN N'no waits' ELSE CONVERT(nvarchar(20), CONVERT(decimal(5, 1), 100.0 * @flush / @total)) + N' %' END, N'often near the top on Linux, not a finding on its own', N'info', N'Corresponds to the fdatasync call SQL Server makes on Linux (Paul Randal, Bob Dorr). Only an issue together with high WRITELOG times.';INSERT @r SELECT 4, N'Flush', N'WRITELOG, average wait per commit wait', CASE WHEN ISNULL(@wl_n, 0) = 0 THEN N'none' ELSE CONVERT(nvarchar(20), CONVERT(decimal(9, 2), 1.0 * @wl_ms / @wl_n)) + N' ms' END, N'a few milliseconds', CASE WHEN @wl_n > 0 AND 1.0 * @wl_ms / @wl_n > 5 THEN N'check' ELSE N'ok' END, N'High values: slow log storage, log in the container writable layer (overlayfs) or very many small transactions.'; /* 3 · Memory */INSERT @r SELECT 5, N'Memory', N'max server memory vs. visible memory', CONVERT(nvarchar(20), @maxmem) + N' MB of ' + CONVERT(nvarchar(20), @mem_mb) + N' MB', N'set, below visible memory, with headroom', CASE WHEN @maxmem >= 2147483647 OR @maxmem > @mem_mb THEN N'check' ELSE N'ok' END, N'On Linux, memory.memorylimitmb (or MSSQL_MEMORY_LIMIT_MB) limits the whole process, max server memory only the buffer pool. In a container, leave 10–20 % headroom to the container limit.';INSERT @r SELECT 6, N'Memory', N'Memory target (committed_target)', CONVERT(nvarchar(20), @target_mb) + N' MB', N'below the container or host limit', N'info', N'2017 and 2019 do not see the cgroup v2 limit of a container and plan with host memory (PSG lab, part 3).';INSERT @r SELECT 7, N'Memory', N'Memory model', @model, N'CONVENTIONAL is normal on Linux', N'info', N'There is no documented equivalent of "Lock Pages in Memory" on Linux.';DECLARE @low int, @proc int;SELECT @low = CONVERT(int, system_low_memory_signal_state) FROM sys.dm_os_sys_memory OPTION (MAXDOP 1);SELECT @proc = CONVERT(int, process_physical_memory_low) FROM sys.dm_os_process_memory OPTION (MAXDOP 1);INSERT @r SELECT 8, N'Memory', N'Memory pressure now (system / process)', CONVERT(nvarchar(5), @low) + N' / ' + CONVERT(nvarchar(5), @proc), N'0 / 0', CASE WHEN @low = 1 OR @proc = 1 THEN N'check' ELSE N'ok' END, N'1 means: SQL Server currently reports too little memory. If it stays 1, check limits and memory-hungry queries.'; /* 4 · CPU, schedulers, parallelism */INSERT @r SELECT 9, N'CPU', N'CPUs and visible schedulers', CONVERT(nvarchar(10), @cpu) + N' CPUs, ' + CONVERT(nvarchar(10), @sched) + N' schedulers' + CASE WHEN @container <> N'n/a' AND @container <> N'NONE' THEN N' · ' + @container ELSE N'' END, N'as many schedulers as CPUs are actually available', N'info', N'SQL Server does not see a CPU quota (docker --cpus). Remedy according to Microsoft: PROCESS AFFINITY and, under cgroup v2, trace flag 8002.';INSERT @r SELECT 10, N'CPU', N'Trace flag 8002 (soft processor affinity)', CASE WHEN @linux = 0 THEN N'n/a (Windows)' WHEN NOT EXISTS (SELECT 1 FROM @tf) THEN N'not readable' WHEN EXISTS (SELECT 1 FROM @tf WHERE TraceFlag = 8002 AND Global = 1) THEN N'on' ELSE N'off' END, N'Microsoft: with PROCESS AFFINITY under cgroup v2; measure first', N'info', N'Without 8002, PROCESS AFFINITY binds the schedulers firmly to individual CPUs. In the PSG lab (2019–2025, quota of 4 CPUs) 8002 added no measurable gain over affinity alone.';INSERT @r SELECT 11, N'CPU', N'max degree of parallelism', CONVERT(nvarchar(10), @maxdop), N'at most 8 per NUMA node, not 0 with more than 8 schedulers', CASE WHEN @maxdop = 0 AND @sched > 8 THEN N'check' ELSE N'ok' END, N'MAXDOP 0 uses as many threads as schedulers are visible, even if the container gets less CPU.';INSERT @r SELECT 12, N'CPU', N'cost threshold for parallelism', CONVERT(nvarchar(10), @ctp), N'not the default of 5', CASE WHEN @ctp = 5 THEN N'check' ELSE N'ok' END, N'The default of 5 lets even very small queries run in parallel.'; /* 5 · tempdb */IF HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW ANY DEFINITION') = 1BEGIN DECLARE @n int, @equal bit, @percent int; SELECT @n = COUNT(*), @equal = CASE WHEN MIN(size) = MAX(size) AND MIN(growth) = MAX(growth) THEN 1 ELSE 0 END, @percent = MAX(CONVERT(int, is_percent_growth)) FROM sys.master_files WHERE database_id = 2 AND type = 0 OPTION (MAXDOP 1); INSERT @r SELECT 13, N'tempdb', N'Data files', CONVERT(nvarchar(10), @n) + N' files, ' + CASE WHEN @equal = 1 THEN N'same size' ELSE N'different sizes' END + CASE WHEN @percent = 1 THEN N', percent growth' ELSE N'' END, N'as many as schedulers, at most 8, same size, fixed growth', CASE WHEN @n < CASE WHEN @sched > 8 THEN 8 ELSE @sched END OR @equal = 0 OR @percent = 1 THEN N'check' ELSE N'ok' END, N'From 2019 a Linux installation creates up to eight files (Microsoft); the container images create one per CPU on first start, up to eight (PSG lab; 2017: one). Not on an in-place upgrade.';ENDELSE INSERT @r SELECT 13, N'tempdb', N'Data files', N'not checked', N'', N'info', N'VIEW ANY DEFINITION is missing (sys.master_files).'; SELECT nr, area, [check], value, guideline, rating, note FROM @r ORDER BY nr OPTION (MAXDOP 1);Sample output
From the lab: SQL Server 2025 CU10 in a container with 6 pinned CPUs and a 6 GB limit, as sa, after a short write load (2,000 single inserts).
nr|area|check|value|guideline|rating|note
--|----|-----|-----|---------|------|----
1|Flush|Flush mode (trace flag 3979)|forced flush (default, 3979 off)|forced flush, unless the storage provably supports FUA|info|Forced flush is the Linux default, even though DBCC TRACESTATUS does not show 3982 as active (PSG lab). 3979 only with FUA-capable storage and alternatewritethrough = 0.
2|Flush|writethrough / alternatewritethrough|in mssql.conf (SQL Server does not show them)|1 / 1 (default) or 1 / 0 with trace flag 3979|info|Check with cat /var/opt/mssql/mssql.conf, section [control]; if it is missing, the defaults apply. writethrough = 0 can lose data.
3|Flush|Share of PREEMPTIVE_OS_FLUSHFILEBUFFERS in waits|22.5 %|often near the top on Linux, not a finding on its own|info|Corresponds to the fdatasync call SQL Server makes on Linux (Paul Randal, Bob Dorr). Only an issue together with high WRITELOG times.
4|Flush|WRITELOG, average wait per commit wait|0.56 ms|a few milliseconds|ok|High values: slow log storage, log in the container writable layer (overlayfs) or very many small transactions.
5|Memory|max server memory vs. visible memory|2147483647 MB of 4916 MB|set, below visible memory, with headroom|check|On Linux, memory.memorylimitmb (or MSSQL_MEMORY_LIMIT_MB) limits the whole process, max server memory only the buffer pool. In a container, leave 10–20 % headroom to the container limit.
6|Memory|Memory target (committed_target)|4267 MB|below the container or host limit|info|2017 and 2019 do not see the cgroup v2 limit of a container and plan with host memory (PSG lab, part 3).
7|Memory|Memory model|CONVENTIONAL|CONVENTIONAL is normal on Linux|info|There is no documented equivalent of "Lock Pages in Memory" on Linux.
8|Memory|Memory pressure now (system / process)|0 / 0|0 / 0|ok|1 means: SQL Server currently reports too little memory. If it stays 1, check limits and memory-hungry queries.
9|CPU|CPUs and visible schedulers|6 CPUs, 6 schedulers · LINUX CONTAINER|as many schedulers as CPUs are actually available|info|SQL Server does not see a CPU quota (docker --cpus). Remedy according to Microsoft: PROCESS AFFINITY and, under cgroup v2, trace flag 8002.
10|CPU|Trace flag 8002 (soft processor affinity)|off|Microsoft: with PROCESS AFFINITY under cgroup v2; measure first|info|Without 8002, PROCESS AFFINITY binds the schedulers firmly to individual CPUs. In the PSG lab (2019–2025, quota of 4 CPUs) 8002 added no measurable gain over affinity alone.
11|CPU|max degree of parallelism|0|at most 8 per NUMA node, not 0 with more than 8 schedulers|ok|MAXDOP 0 uses as many threads as schedulers are visible, even if the container gets less CPU.
12|CPU|cost threshold for parallelism|5|not the default of 5|check|The default of 5 lets even very small queries run in parallel.
13|tempdb|Data files|6 files, same size|as many as schedulers, at most 8, same size, fixed growth|ok|From 2019 a Linux installation creates up to eight files (Microsoft); the container images create one per CPU on first start, up to eight (PSG lab; 2017: one). Not on an in-place upgrade.Method and limits
SQL Server 2017 CU31, 2019 CU32, 2022 CU27 and 2025 CU10 (Docker images from Microsoft), main run on Nov 13, 2026; a fresh container for each variant. Measured in Linux containers on an Ubuntu 24.04 VM under Hyper-V (24 vCPUs, 2 NUMA nodes, ext4 on virtual disks of a SATA SSD, disks without FUA, no swap, Docker with overlayfs and cgroup v2). Limits:
- No change to the host: The effect of THP, swappiness, NUMA balancing, read-ahead, TuneD,
noatimeand XFS is not measured; Microsoft's recommendations apply for these. - Container instead of installation: We did not check the defaults of a package installation (
mssql-conf setup). - Virtual disks without FUA: The FUA path should only be read as a measured value here.
- Synthetic loads, a few runs per case; times show orders of magnitude.
Further reading
- Microsoft Learn: "Performance best practices and configuration guidelines for SQL Server on Linux" (memory, kernel, CPU, network), "Performance best practices: SQL Server memory on Linux", "tempdb database" (Linux section).
- Bob Dorr: "SQL Server on Linux: Forced Unit Access (FUA) Internals" (Microsoft Tech Community).
- Paul Randal: "PREEMPTIVE_OS_FLUSHFILEBUFFERS waits on Linux" (SQLskills).
- In this series: Overview, Part 1, Part 2: For Windows admins, Part 3: What goes wrong in the container.
- At PSG: tempdb guide, SQL Server performance.
Running SQL Server on Linux?
Three things first: run the host check and decide deliberately which deviations you keep. Use Query 4 to check memory limits, schedulers and tempdb. And before you turn any knobs on flushing, find out whether your storage supports FUA at all.