Unless you’ve asked for syslog, every message PostgreSQL logs is a write to standard error. That’s the whole mechanism, and the two dozen log_* parameters that follow this one are elaborations on it: what gets written, in what format, and what catches it on the other side. These two parameters are the “what catches it” part, so this post carries the plumbing and the rest of the cluster can point back here. (logging_collector sorts after log_truncate_on_rotation; I’ve pulled it forward because log_destination can’t be explained without it.)
log_destination is a comma-separated list, default stderr, context sighup. The choices are stderr, csvlog (since 8.3), jsonlog (since 15), syslog, and on Windows eventlog. logging_collector is a boolean, default off, context postmaster: turning it on requires a restart, which is the single most consequential fact in this post, because you will want it on and you will discover that on a running production server.
What the collector is
With the collector off, stderr means the process’s actual standard error, wherever the thing that started the postmaster pointed it: a file from pg_ctl -l, the journal under systemd, the container’s stdout under Docker. Every backend inherits that descriptor and writes each message to it with one write(). That works, up to a point. There is no rotation, and on some platforms concurrent writers to one file can garble each other.
With the collector on, the postmaster creates a pipe, points its own standard error at the write end, and forks a child (postgres: logger in ps) that owns the read end and turns what comes out of it into files. Every subsequent child inherits standard error already pointed at the pipe. Backends don’t write bare text into it; each message goes through a small chunking protocol (a header with the PID, a length, the destination format, and a last-chunk flag) in pieces no larger than PIPE_BUF, 4096 bytes on Linux, so that each write is atomic and the collector can reassemble a long message from one backend even when another backend’s chunks land in between. That’s the actual reason multi-line EXPLAIN output from auto_explain comes out intact instead of shuffled together with a neighbor’s.
Anything that arrives on the pipe without the header is written straight through to the text-format log file. That is where output from a shared library that complains on stderr, from the shell run by archive_command, or from COPY ... TO PROGRAM ends up, with no timestamp and no prefix, because it didn’t come from PostgreSQL’s logging code. This is the collector’s quiet advantage over syslog: the dynamic linker does not know how to call syslog().
Two things follow from that. First, the text-format .log file always exists when the collector is on, whatever log_destination says. The postmaster opens it before forking the logger, to prove log_directory is writable, and the raw pass-through text needs somewhere to go. Set log_destination = 'csvlog' and you get a .csv full of messages and a .log containing the line that says logging moved to csvlog, plus whatever any shell decided to say. Second, the logger is started after the configuration files are read, the data directory checked, and shared memory allocated, so a failure in any of those is reported on the original standard error, not in log_directory. If the server won’t come up, look in pg_ctl’s -l file or the journal before you look in log/.
The collector is also the one child the postmaster does not kill during a crash cycle. When a backend dies of a signal and everything else is terminated and reinitialized, the logger keeps its PID and its files, which is why was terminated by signal 9 reliably appears in the log rather than in the void. And current_logfiles in the data directory, along with pg_current_logfile() (both since 10), tells you the file the collector is writing right now, per format; both are absent when the collector is off.
The formats
stderr is the human-readable text format, shaped by log_line_prefix. csvlog and jsonlog are the same messages with every field made explicit, in files that get .csv or .json in place of log_filename’s .log. You can list several at once, and the collector writes each file. Pick jsonlog over csvlog where you can: CSV log lines contain embedded newlines in the query and context fields, and every CSV parser that “handles multi-line values” handles them slightly differently.
The documentation says the collector must be on to get CSV or JSON output. What it doesn’t say is what happens if it isn’t: nothing, silently. In the sandbox, log_destination = 'jsonlog' with the collector off produced ordinary text on standard error, no warning, and pg_current_logfile() returned null. If you set this on a Debian box and wonder where the JSON went, that’s where.
syslog hands each message to the local syslog daemon (see syslog_facility and friends, later in the alphabet). Two properties matter. Syslog splits long messages at 1024 bytes by default, and it drops messages when it can’t keep up. The collector does neither, and that leads to the trade-off that actually decides this configuration.
The trade the collector makes
The pipe has a fixed capacity, 64 kB by default on Linux. The collector is designed never to lose a message, so when it falls behind, the pipe fills, and the next process that tries to log blocks in write() until the collector drains it. Every process. A log directory on slow storage, or a burst of log_statement = 'all' on a busy server, can stall the entire instance behind its own log file, and it presents as everything being slow at once for no reason pg_stat_activity can show you (the backends are in a kernel write, not in a wait event). Syslog resolves the same situation by throwing messages away. Neither choice is wrong; you should know which one you made.
A full disk is a different case. The collector’s write fails, it notes the failure on its original standard error, and the message is dropped; backends don’t block. So the collector loses messages when the disk is full and stalls when the disk is slow, and syslog loses messages under load and never stalls.
Where you actually are
The defaults are the least interesting configuration, because almost nobody runs them. Debian and Ubuntu leave the collector off and start each cluster with pg_ctl -l /var/log/postgresql/postgresql-18-main.log, then rotate that file weekly with logrotate’s copytruncate. That is precisely the configuration the documentation calls suitable only for low volumes, running on a large fraction of the world’s PostgreSQL servers, and it mostly works because each message is a single write() to a file opened for append, which Linux doesn’t interleave in practice. What bites is copytruncate: lines written between the copy and the truncate are lost, and on a busy server that window is not empty. The PGDG RPMs turn the collector on and rotate daily into log/ inside the data directory. The official Docker image leaves the collector off so that docker logs works; turn it on inside a container and the logs move into the data volume and out of anything your platform is collecting. Managed services turn it on and don’t ask you.
So: collector on, restart, log_destination = 'stderr', and add jsonlog (or csvlog before 15) if something other than a person reads the logs, keeping stderr in the list so the pass-through text still has a home. Point log_directory somewhere outside the data directory; that argument is the next post’s, but the answer is not in doubt. Use syslog only if everything else on the host already goes through syslog and you’ve made peace with it dropping lines. And if you are on Debian and it’s working, understand that it’s working because of a kernel property and a weekly gamble, and turn the collector on before the gamble stops paying.