Anthropomorphic white eagle wearing safety glasses and work apron measures a log with a tape measure at a woodworking bench surrounded by hand tools.

Every other statement-logging parameter in this cluster decides whether a query goes into the log. These two decide whether your users’ data goes with it.

Under the extended query protocol the statement text arrives with $1, $2 placeholders and the values arrive separately, in the Bind message; those values are the bind parameters. When log_statement, log_min_duration_statement, or the sampling parameters decide a statement gets logged, PostgreSQL reunites the two:

1LOG: execute <unnamed>: insert into users (email, note) values ($1, $2)
2DETAIL: Parameters: $1 = 'alice@example.com', $2 = 'super secret note'

log_parameter_max_length governs that DETAIL line. The default is -1, which logs every value in full. 0 drops the line entirely. A positive value clips each parameter to that many bytes (multibyte-aware, so it will not cut a UTF-8 character in half) and appends ...; at 10, the line above becomes $1 = 'alice@exam...', $2 = 'super secr...'. The context is superuser, on the theory that what goes into the server log is the DBA’s business.

Both parameters arrived in PostgreSQL 13, so every supported version has them. Before 13 the DETAIL line was unconditional and uncapped: turn on statement logging and every bind parameter went to disk, in full, for as long as you kept logs. The -1 default preserves that for people upgrading, which is a fine reason for a default and a poor reason to keep it.

Which values, exactly

The word that matters is bind. If the driver interpolates values into the SQL text before sending it, there are no bind parameters, the statement logs as a plain statement: line with the literals inline, and log_parameter_max_length has nothing to act on:

1LOG: statement: select * from users where email = 'alice@example.com'

Nothing in PostgreSQL redacts literals out of statement text; the docs’ warning under log_statement that logged statements “might reveal sensitive data and even contain plaintext passwords” applies here in full. Whether you are protected depends on the driver, and the Python ecosystem manages to land on both sides of the line: psycopg2 interpolates on the client; psycopg 3 binds on the server by default; Django’s psycopg 3 backend switches back to client-side binding unless you set server_side_binding: True. JDBC, Npgsql, pgx, asyncpg, and node-postgres (when you pass the parameters instead of formatting them into the string) all bind server-side. Check yours by logging one statement and seeing whether the log line says execute or statement.

Two edges. log_error_verbosity = terse strips DETAIL and CONTEXT from every log message, so at terse neither of these parameters does anything and no bind parameter reaches the log; log_error_verbosity covers what else that costs you. And auto_explain has its own auto_explain.log_parameter_max_length, same semantics, same -1 default, for the parameter list it attaches to logged plans.

On error, and why the default flips

log_parameter_max_length_on_error is the same dial for a different message. When a statement fails, PostgreSQL can attach the bind parameters to the error as a CONTEXT line:

1ERROR: duplicate key value violates unique constraint "users_email_key"
2DETAIL: Key (email)=(alice@example.com) already exists.
3CONTEXT: unnamed portal with parameters: $1 = 'alice@example.com', $2 = 'super secret note'
4STATEMENT: insert into users (email, note) values ($1, $2)

The values mean the same thing (-1 full, 0 off, a positive number a byte cap), but the default is 0 and the context is user. Both differences are deliberate.

The default is 0 partly because this was new in 13 with nothing to stay compatible with, and partly because it costs something on every statement, not just the failing ones. To have the values available when an error happens, the server builds the textual parameter list at Bind time and keeps it in the portal, before anyone knows whether execution will fail. For parameters sent as text that is a copy, trimmed to the cap; for parameters sent in binary format, which drivers like Npgsql and asyncpg use for nearly every type, it means calling the type’s output function for every parameter of every statement. It is not a large cost, but it is paid whether or not the statement fails.

The context is user because that CONTEXT line is not only written to the log; it is sent to the client as part of the error. Turning this on changes what the application sees in its exception message, and the project’s position is that the client gets a say in that. The consequence for the DBA is that there is no ceiling: any role can SET log_parameter_max_length_on_error = -1 and put its own parameters into your server log at full length, and the only server-side veto is terse.

The reason to be careful with it is that error logging is ungated. Statement logging is filtered by duration, by sampling, or by mod; errors are logged always, because log_min_messages defaults to WARNING and log_min_error_statement to ERROR. Failing statements are the ones fed by bad input: the validation the application forgot, the fuzzer, the injection attempt, the login form. And error text is the part of the log that travels. Once the parameters ride along in CONTEXT they are in the exception the application caught, which means they are in Sentry, in the aggregator, in the Slack alert, and in the ticket someone pasted the traceback into. The PostgreSQL log is the least of the places those values end up.

In fairness to the parameter, PostgreSQL leaks values in errors without its help. The unique_violation above printed the email in its DETAIL line; an input-syntax error quotes the offending value in the message itself. With the default of 0, a bad parameter produces this:

1ERROR: invalid input syntax for type integer: "not-an-int"
2CONTEXT: unnamed portal parameter $1 = '...'

A conscientiously redacted CONTEXT line directly beneath an ERROR line that printed the value. So the parameter does not control whether a value appears in an error; it controls whether the other values do, the ones that had nothing to do with the failure. For a unique violation on email, that is the difference between one address in the log and the whole row. (psql 16 and later will show you all of this: SELECT $1::int \bind 'abc' \g sends a real bind parameter.)

A positive cap, on either parameter, is a bloat control and not a privacy control. An email address, a phone number, and a Social Security number all fit comfortably in 64 bytes; truncation only redacts values that were too long to be identifiers in the first place. It keeps the ids and dates you need to reproduce a slow plan, drops the JSON documents you do not, and bounds the memory the on-error copy costs. It does nothing for the data you were worried about.

So log_parameter_max_length gets a cap (64 is reasonable) on any server that logs statements, and if the log leaves the host for a service you do not control and nothing redacts it on the way, it gets 0 instead; take the values from pg_stat_statements and EXPLAIN by hand, because the log is a data store, whatever your data inventory says. log_parameter_max_length_on_error stays at 0, and when you need it you turn it on where you need it: SET LOCAL around the transaction you are debugging, or ALTER ROLE ... SET on the one application role whose parameters you have looked at, with a cap, and with the knowledge that you have just changed what that application’s exceptions contain.