Skip to content
About
Main content

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 () 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 ?


Predict

In PostgreSQL running in Docker, what does synchronous_commit = off do to one client's throughput?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    group-commit-measure-fsync-and-the.py58 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    group-commit-measure-fsync-and-the.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

Durable writes on the laptop
Partµs per record
A: flush every record2,980
B: flush every 20 records159

pgbench in Docker

pgbench in Docker
synchronous_commitClientstpsLatency (ms)
on14.51K0.222
on3225.1K1.27
off14.24K0.236
off3250.4K0.635
Reference runApple M2 · 8 cores · 8 GB
Output · uv run group-commit-measure-fsync-and-the.py
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 ms
macOS 26.6.2 · Python 3.13.3 · postgres:17.6 · Docker 28.5.1 · run 2026-10-25

What 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.