lo_compat_privileges sets large-object security back to PostgreSQL 8.4, the last release in which a large object belonged to nobody in particular and any role that could connect to the database could read it, overwrite it, or delete it. PostgreSQL 9.0 gave large objects an owner and an ACL. This parameter is the switch that pretends it didn’t.

Default off. Context superuser, so no restart is involved: a superuser can SET it in a session, pin it to a role or a database with ALTER ROLE ... SET or ALTER DATABASE ... SET, or, from PostgreSQL 15, delegate it with GRANT SET ON PARAMETER lo_compat_privileges. Each of those is a way to lower the security of an entire database from a place nobody will think to look.

Since 9.0, every large object has a row in pg_largeobject_metadata carrying an owner and an ACL, and the rules are the ones you would expect from a table: SELECT to read, UPDATE to write or truncate, and ownership to lo_unlink() it, comment on it, grant on it, or give it away. With lo_compat_privileges = on, the read and write checks in lo_open() are skipped (and with them everything built on it: lo_get(), lo_put(), lo_read(), lo_truncate(), the client-side lo_export()), the ownership check in lo_unlink() is skipped, and so is the one guarding COMMENT ON LARGE OBJECT and SECURITY LABEL. That is the whole list; I checked it against the 18 source and then on a scratch cluster, because the docs describe the boundary only loosely. GRANT ... ON LARGE OBJECT still requires grant options, ALTER LARGE OBJECT ... OWNER TO still requires being the owner (or a member of the owner’s role), and the server-side lo_import() and lo_export() still require EXECUTE, which has been revoked from PUBLIC since PostgreSQL 11 for the excellent reason that those two functions read and write the server’s filesystem as the server’s OS user.

Nobody sets this for compatibility with 8.4. The reasons it gets turned on in 2026 are more embarrassing than that. The usual one: the large objects were imported by one role (a migration user, a superuser running \lo_import, an ETL job) and the application connects as a different role, so the first lo_get() fails with permission denied for large object, and someone discovers that this parameter makes the error go away. The other: vacuumlo calls lo_unlink() on every orphan it finds, and if it is connecting as a role that doesn’t own them, the first one fails with must be owner of large object, which leaves its batch transaction in the error state and ends the run.

Both have correct fixes, and the fixes are cheap. Run vacuumlo as the owner or as a superuser. For the ownership mismatch, pg_largeobject_metadata tells you exactly which objects belong to whom, and \gexec does the rest:

1SELECT format('ALTER LARGE OBJECT %s OWNER TO app;', oid)
2 FROM pg_largeobject_metadata
3 WHERE lomowner <> 'app'::regrole \gexec

If the application only needs to read them, GRANT SELECT ON LARGE OBJECT in the same loop is the narrower version. (The real fix is to stop storing files as large objects, but that’s a different post and a different budget.)

This parameter belongs to the compatibility family with array_nulls, backslash_quote, and escape_string_warning, but it is the member with the worst personality. The others preserve a syntax or a format. This one disables a permissions model for every large object in the database, and does so silently: there is no log line, no warning, nothing in any statistics view that says large objects are currently world-writable. The only evidence is SHOW lo_compat_privileges, and the only people who run that are the ones who already suspect.

Leave it off. If you inherit a database where it’s on, turn it off in a session, find the query that breaks, and fix that query’s object with a GRANT or an ALTER ... OWNER. The error was the bug report. The parameter was someone declining to read it.