Group commit: measure fsync and the self-tuning batch
Measure your machineNeeds Python, Docker
Problem
A durable commit waits for a flush to stable storage, a fixed cost per flush. Sharing one flush among many commits (group commit) divides that cost; turning synchronous commit off stops waiting altogether and accepts a window of recent commits that a crash can lose. With many concurrent clients, group commit happens on its own: commits arriving during a flush share the next one.
Part A: measure the raw cost of one durable write (on macOS with F_FULLFSYNC, which reaches the device). Part B: write 20 records, then flush once, and compare the per-record cost. Part C: run pgbench in the PostgreSQL container with synchronous_commit = on, then off, with 1 and 32 clients, and compare transactions per second.
The question. What does one durable write cost, what does batching save, and how do 32 clients and synchronous_commit change PostgreSQL's throughput?
Predict
A laptop SSD shows tens to hundreds of microseconds per fsync (more with full-flush semantics). Batching 20 records gives roughly 20 times lower cost per record. synchronous_commit = off gives much higher throughput, and with more clients the commits share flushes, so total throughput rises far more than one client's latency alone would allow.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
group-commit-measure-fsync-and-the.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Group commit: measure fsync, and share it. Part A: the cost of one durable 200-byte write (fsync; F_FULLFSYNC on macOS, which is what reaches the device there). Part B: 20 records, then one flush. Part C: pgbench in the PostgreSQL container, 1 and 32 clients, with synchronous_commit on and off. Part C needs the PostgreSQL service from the compose file (port 55805) and the docker CLI. """ import argparse, json, os, re, subprocess, sys, tempfile, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--writes", type=int, default=2000, help="Durable writes in part A.") p.add_argument("--batch", type=int, default=20, help="Records per flush in part B.") p.add_argument("--container", default="group-commit-measure-fsync-and-the-postgres-1", help="PostgreSQL container for part C.") p.add_argument("--seconds", type=int, default=30, help="pgbench duration.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def flush(fd): if sys.platform == "darwin": import fcntl; fcntl.fcntl(fd, fcntl.F_FULLFSYNC) else: os.fsync(fd) path = os.path.join(tempfile.mkdtemp(), "probe.log"); fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND) t = time.perf_counter() for _ in range(args.writes): os.write(fd, b"x" * 200); flush(fd) per_a = (time.perf_counter() - t) / args.writes * 1e6 t = time.perf_counter(); n = 0 while n < args.writes: for _ in range(args.batch): os.write(fd, b"x" * 200); n += 1 flush(fd) per_b = (time.perf_counter() - t) / args.writes * 1e6 os.close(fd); os.remove(path) parts = [{"part": "A: flush every record", "us_per_record": per_a}, {"part": f"B: flush every {args.batch} records", "us_per_record": per_b}] def sh(*a): return subprocess.run(["docker", "exec", args.container, *a], capture_output=True, text=True) pg = [] if sh("true").returncode == 0: sh("pgbench", "-U", "postgres", "-i", "-s", "10", "postgres") for sync in ("on", "off"): for clients in (1, 32): out = subprocess.run(["docker", "exec", "-e", f"PGOPTIONS=-c synchronous_commit={sync}", args.container, "pgbench", "-U", "postgres", "-c", str(clients), "-j", str(min(clients, 4)), "-T", str(args.seconds), "-N", "postgres"], capture_output=True, text=True) tps = float(re.search(r"tps = ([\d.]+)", out.stdout).group(1)) lat = float(re.search(r"latency average = ([\d.]+) ms", out.stdout).group(1)) pg.append({"synchronous_commit": sync, "clients": clients, "tps": tps, "latency_ms": lat}) summary = {"batch_gain": per_a / per_b} for r in parts: print(f"{r['part']:<28} {r['us_per_record']:9.1f} µs per record") for r in pg: print(f"pgbench synchronous_commit={r['synchronous_commit']:<3} clients={r['clients']:>2}: {r['tps']:9.0f} tps, latency {r['latency_ms']:.2f} ms") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"flushes": parts, "pgbench": pg}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
group-commit-measure-fsync-and-the.compose.yml # PostgreSQL for the group-commit-measure-fsync-and-the lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"] environment: POSTGRES_PASSWORD: lab ports: ["55805:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f group-commit-measure-fsync-and-the.compose.yml up -d --wait uv run group-commit-measure-fsync-and-the.py uv run group-commit-measure-fsync-and-the.py --help # every knob docker compose -f group-commit-measure-fsync-and-the.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --writes2000 | Durable writes in part A. |
| --batch20 | Records per flush in part B. |
| --containergroup-commit-measure-fsync-and-the-postgres-1 | PostgreSQL container for part C. |
| --seconds30 | pgbench duration. |
Our run
Durable writes on the laptop
| Part | µs per record |
|---|---|
| A: flush every record | 2,980 |
| B: flush every 20 records | 159 |
pgbench in Docker
| synchronous_commit | Clients | tps | Latency (ms) |
|---|---|---|---|
| on | 1 | 4.51K | 0.222 |
| on | 32 | 25.1K | 1.27 |
| off | 1 | 4.24K | 0.236 |
| off | 32 | 50.4K | 0.635 |
A: flush every record 2981.5 µs per record
B: flush every 20 records 159.4 µs per record
pgbench synchronous_commit=on clients= 1: 4512 tps, latency 0.22 ms
pgbench synchronous_commit=on clients=32: 25148 tps, latency 1.27 ms
pgbench synchronous_commit=off clients= 1: 4243 tps, latency 0.24 ms
pgbench synchronous_commit=off clients=32: 50432 tps, latency 0.64 msWhat we found
A full flush cost 2,980 µs per record; flushing every 20 records cut it to 159 µs (18.7×). In PostgreSQL, one client committed 4.51K transactions per second with synchronous commit on and 4.24K with it off: no difference, because the container's virtual disk acknowledges flushes without reaching the laptop's SSD. With 32 clients, on gave 25.1K and off 50.4K.
Read it
Part A and B are the real numbers for this laptop's SSD; Part C measures Docker's disk, which treats a flush as nearly free, so it understates what synchronous commit costs on real storage. Run Part C on the machine whose durability you care about. The shape still shows: more clients share flushes, and turning synchronous commit off buys throughput with a named loss window.