Postgres Cannot Execute in a Read-Only Transaction
Chat2DB TeamA write that has always worked starts failing:
ERROR: cannot execute INSERT in a read-only transactionThe verb changes with the statement (UPDATE, DELETE, CREATE TABLE, nextval(), SELECT FOR UPDATE), and the SQLSTATE is always 25006 (read_only_sql_transaction). PostgreSQL is telling you that the transaction is in read-only mode. The real question is why. There are two very different answers:
- You are connected to a standby (a read replica), which can never accept writes.
- You are on a writable server, but a setting put the transaction in read-only mode:
BEGIN READ ONLY, ordefault_transaction_read_onlyset for the session, the role or the database.
This guide shows how to tell which one you have in ten seconds, then how to fix each, with output from PostgreSQL 18.
Step 1: Primary or Standby?
Run this on the connection that fails:
SELECT pg_is_in_recovery();
SHOW transaction_read_only;
SHOW default_transaction_read_only;pg_is_in_recovery() | default_transaction_read_only | Meaning |
|---|---|---|
t | (any) | You are on a standby. Writes are impossible. Connect to the primary. |
f | on | A setting makes transactions read-only. Find where it comes from. |
f | off | The transaction itself was started read-only (BEGIN READ ONLY, or a driver or framework flag). |
On a standby the output looks like this:
pg_is_in_recovery
-------------------
t
transaction_read_only
-----------------------
on
default_transaction_read_only
-------------------------------
offtransaction_read_only is on even though the default is off. During recovery every transaction is read-only, and you cannot change that:
SET default_transaction_read_only = off; -- accepted, but has no effect
BEGIN READ WRITE;
-- ERROR: cannot set transaction read-write mode during recoveryCase 1: Connected to a Standby
A hot standby replays WAL from the primary and serves only read queries. It rejects anything that would write, including some statements that do not look like writes:
INSERT ... ERROR: cannot execute INSERT in a read-only transaction
CREATE TEMP TABLE ... ERROR: cannot execute CREATE TABLE in a read-only transaction
SELECT nextval(...) ERROR: cannot execute nextval() in a read-only transaction
SELECT ... FOR UPDATE ERROR: cannot execute SELECT FOR UPDATE in a read-only transaction
ANALYZE orders ERROR: cannot execute ANALYZE during recovery
VACUUM orders ERROR: cannot execute VACUUM during recoveryAdvisory locks, on the other hand, work on a standby, because they live only in memory.
Typical ways an application ends up on a standby:
Wrong endpoint or host. Managed services expose separate writer and reader endpoints (for example the cluster and reader endpoints of Aurora, or replica hostnames in Cloud SQL, Azure and others). A copied connection string, or an environment variable pointing at the reader, sends writes to a replica.
A load balancer or DNS name that includes replicas. If one hostname round-robins across the primary and replicas, some connections land on a replica and fail randomly.
Failover. After a failover the old primary may come back as a standby. Pooled connections opened before the switch stay connected to it, and their writes fail until they reconnect. This often looks like a sudden burst of 25006 errors right after a maintenance event.
Fix: tell the client to require a writable server. libpq (psql, Python's psycopg, Ruby's pg, PHP and many others) can list several hosts and pick the one that accepts writes:
postgresql://db1:5432,db2:5432/app?target_session_attrs=read-writeThe JDBC driver has the equivalent option:
jdbc:postgresql://db1:5432,db2:5432/app?targetServerType=primaryWith these options a connection attempt that reaches a standby is rejected and the client tries the next host. After a failover, new connections go to the new primary. Also set a reasonable connection lifetime in your pool (HikariCP maxLifetime, for example) so old connections are replaced.
Fix: split reads and writes on purpose. If you use replicas to scale reads, route only read-only work to them and keep everything that writes, including nextval(), temporary tables and SELECT FOR UPDATE, on the primary. Most frameworks support a read replica configuration for exactly this.
Case 2: Read-Only Mode by Setting
On a primary, transactions are read-only when you ask for it explicitly:
BEGIN READ ONLY;
INSERT INTO orders (total) VALUES (10);
-- ERROR: cannot execute INSERT in a read-only transaction
ROLLBACK;They are also read-only by default when default_transaction_read_only is on. That setting can come from four places, and the last one wins:
-- 1. postgresql.conf or ALTER SYSTEM (whole server)
-- 2. per database
ALTER DATABASE app SET default_transaction_read_only = on;
-- 3. per role (or role + database)
ALTER ROLE reporter SET default_transaction_read_only = on;
-- 4. per session
SET default_transaction_read_only = on;Role and database settings are often used on purpose, to make a reporting user read-only, and then forgotten. A user who later needs to write gets the error even with the right GRANTs:
CREATE ROLE reporter LOGIN;
GRANT SELECT, INSERT ON orders TO reporter;
ALTER ROLE reporter SET default_transaction_read_only = on;
-- connected as reporter:
INSERT INTO orders (total) VALUES (10);
-- ERROR: cannot execute INSERT in a read-only transactionFind which level set it with pg_db_role_setting and pg_settings:
SELECT coalesce(r.rolname, '(all roles)') AS role,
coalesce(d.datname, '(all databases)') AS database,
s.setconfig
FROM pg_db_role_setting s
LEFT JOIN pg_roles r ON r.oid = s.setrole
LEFT JOIN pg_database d ON d.oid = s.setdatabase; role | database | setconfig
----------+-----------------+------------------------------------
reporter | (all databases) | {default_transaction_read_only=on}SELECT setting, source, sourcefile FROM pg_settings
WHERE name = 'default_transaction_read_only';source tells you whether the value came from the configuration file, the database, the user or the session.
Fix: reset it at the right level.
ALTER ROLE reporter RESET default_transaction_read_only;
-- or
ALTER DATABASE app RESET default_transaction_read_only;The change applies to new sessions. Existing connections keep the old value until they reconnect.
The Trap: A Database You Cannot Reset
If you set default_transaction_read_only = on on the database you are connected to, the ALTER DATABASE ... RESET that would undo it is itself a write, and fails:
ALTER DATABASE b5 RESET default_transaction_read_only;
-- ERROR: cannot execute ALTER DATABASE in a read-only transactionOverride the default in your session first, or start an explicit read-write transaction, then reset:
SET default_transaction_read_only = off;
ALTER DATABASE b5 RESET default_transaction_read_only; -- ALTER DATABASEThis works because the session setting is applied after the database setting. On a standby, by contrast, no setting can make a transaction writable.
Drivers and Frameworks That Start Read-Only Transactions
Some libraries put transactions into read-only mode for you:
- Spring
@Transactional(readOnly = true)makes the JDBC driver runSET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY(orBEGIN READ ONLY, depending on the driver'sreadOnlyModesetting). A write inside such a method, or a nested method that joins the same transaction, fails with 25006. - JDBC
connection.setReadOnly(true)has the same effect, and the flag can leak to the next user of a pooled connection if the pool does not reset it. - psycopg
conn.read_only = Trueand SQLAlchemy execution options can set it too.
Finally, the mode must be chosen before the first query of a transaction:
BEGIN READ ONLY;
SELECT 1;
SET TRANSACTION READ WRITE;
-- ERROR: transaction read-write mode must be set before any queryTemporary Tables in Read-Only Transactions
On a primary, a read-only transaction may write to temporary tables that already exist, which is handy for reports:
CREATE TEMP TABLE scratch (x int); -- outside the read-only transaction
BEGIN READ ONLY;
INSERT INTO scratch VALUES (1); -- INSERT 0 1
COMMIT;Creating the temporary table inside the read-only transaction is not allowed (cannot execute CREATE TABLE in a read-only transaction). On a standby, temporary tables cannot be created at all.
Logical Replication Subscribers Are Writable
Do not confuse a physical standby with a logical replication subscriber. A subscriber created with CREATE SUBSCRIPTION is an ordinary primary: pg_is_in_recovery() returns f and it accepts writes, including writes to the replicated tables. That is a different risk, because local changes can conflict with incoming changes and stop replication. If you want a subscriber to be read-only for applications, revoke INSERT, UPDATE and DELETE from application roles on the replicated tables. Privileges apply only to the roles you revoke them from, so the subscription's own apply process keeps working.
Quick Checklist
SELECT pg_is_in_recovery();. Ift, fix the host, endpoint ortarget_session_attrs.SHOW default_transaction_read_only;. Ifon, checkpg_db_role_settingandpg_settings.source, then reset at that level.- If both are off, look for
BEGIN READ ONLY,readOnly = trueorsetReadOnly(true)in the application. - After a failover, recycle pooled connections.
If you switch between primaries and replicas often, it helps to see which server each connection points to. Chat2DB (opens in a new tab) lets you save separate writer and reader connections, shows server status per connection, and its AI assistant can explain errors like this one from the actual session settings.
FAQ
Why does nextval() fail in a read-only transaction?
nextval() changes the sequence and writes WAL, so PostgreSQL treats it as a write. Inserts that rely on a serial or identity default therefore fail on standbys and in read-only transactions, even before the row is inserted.
Can I make a standby accept writes?
No. A standby is in recovery and only replays changes from the primary. To make it writable you must promote it (pg_promote() or pg_ctl promote), which ends replication and turns it into an independent primary.
Does default_transaction_read_only protect against malicious users?
No. Any user can run SET default_transaction_read_only = off or BEGIN READ WRITE in their own session. It is a safety net against mistakes. For real protection, grant only SELECT privileges, or point the user at a standby.
