The io_* parameters we’ve covered so far describe the I/O requests: how big each one is, how many a process may have in flight. These two decide who actually performs them. I wrote about the architecture, and about what PostgreSQL 19 does to it, in AIO Grows Up; this is the parameter-level view, and it includes a correction to something I said there.
io_method is the switch that selects PostgreSQL 18’s asynchronous I/O execution engine. Context postmaster, so it costs a restart. The default is worker, and there are three values. sync executes what the documentation calls “asynchronous-eligible” I/O synchronously; it is AIO off, the escape hatch, and roughly the PostgreSQL 17 behavior (read streams and I/O combining still apply, since those predate the AIO subsystem). worker hands I/O to a pool of dedicated background processes over shared memory; it works on every platform PostgreSQL builds on, which is why it won the default. io_uring submits I/O directly to the Linux kernel, with no extra processes in the path, and is the fastest of the three where you can have it: it requires a build configured with --with-liburing (the PGDG packages are; if yours isn’t, the value simply won’t be accepted), a reasonably current kernel, and a security policy that hasn’t disabled io_uring, which container seccomp profiles and hardened distributions frequently have. The per-backend rings are created by the postmaster at startup and sized by io_max_concurrency, which has a useful diagnostic consequence: a policy-blocked io_uring announces itself as a server that will not start, not as one that runs slowly. Flip this parameter in staging first, for exactly that reason.
io_workers sets the size of the worker pool, and matters only under io_method = worker. The default is 3, the range is 1 to 32, and, the correction: its context is sighup, not postmaster. In “AIO Grows Up” I said you pick the pool size at boot and live with it. You don’t; you live with it until the next reload:
1 ALTER SYSTEM SET io_workers = 8;
2 SELECT pg_reload_conf();
1 $ ps -o cmd | grep -c 'io worker'
2 8
The static-pool complaint stands, since the pool still doesn’t size itself until PostgreSQL 19, but resizing it is a reload, not a restart.
One piece of worker-mode mechanics informs everything about sizing this parameter. There is a single submission queue for the whole cluster, and when a backend can’t get its I/O into it, because the queue is full or the workers are behind, the backend performs the I/O itself, synchronously. An undersized pool therefore never errors and never logs; it quietly degrades toward io_method = sync, one I/O at a time, while the three default workers do their best. The pressure is visible, though: contention on the queue shows up as the LWLock wait event AioWorkerSubmissionQueue in pg_stat_activity, and that event climbing under load is the pool telling you it’s too small. (Backends waiting on AioIoCompletion are just waiting for I/O, which is normal and healthy.) Two other observability notes: the workers appear in pg_stat_activity with backend_type = 'io worker', and pg_stat_io attributes reads to the backend that requested them, not to the worker that executed them, so don’t go looking for your sequential scan’s I/O under the workers’ rows.
Recommendations, then. For io_method: stay on worker unless you run a current Linux kernel under your own control and your security posture permits io_uring, in which case use io_uring; reserve sync for debugging and for platforms where AIO misbehaves. For io_workers: 3 is calibrated to start on a Raspberry Pi. On a server doing sustained cold reads, raise it; published benchmarks generally land somewhere between a quarter and half the CPU thread count, it costs only a reload to experiment, and PostgreSQL 19 replaces the guess with a self-sizing pool anyway. Watch AioWorkerSubmissionQueue, not vibes.