Understanding and using transactions in PostgreSQL
Introduction
Transactions are a mechanism that encapsulates multiple statements into a single operation for the database to process. Instead of feeding in individual statements, the database is able to interpret and act on the group of commands as a cohesive unit. This helps ensure the consistency of the dataset over the course of many closely related statements.
In this guide, we'll start by discussing what transactions are and why they are beneficial. Afterwards, we'll take a look at how PostgreSQL implements transactions and the various options you have when using them.
What are transactions?
Transactions are a way to group together and isolate multiple statements for processing as a single operation. Each statement in a transaction still runs as soon as it is sent to the server, but its changes stay inside the transaction: they only become permanent, and visible to other sessions, when the whole transaction is committed.
Isolation is an important part of transactions. Within a transaction, the executed statements can only affect the environment within the transaction itself. From inside the transaction, statements can modify data and the results are immediately visible. From the outside, no changes are made until the transaction is committed, at which time all of the actions within the transaction become visible at once.
These features help databases achieve ACID compliance by providing atomicity (actions in a transaction are either all committed or all rolled back) and isolation (outside of the transaction, nothing changes until the commit while inside, the statements have consequences). These together help the database maintain consistency (by guaranteeing that partial data transformations cannot occur). Furthermore, changes in transactions are not returned as successful until they are committed to non-volatile storage, which provides durability.
To achieve these goals, transactions employ a number of different strategies and different database systems use different methods. PostgreSQL uses a system called Multiversion Concurrency Control (MVCC), which allows the database to perform these actions without unnecessary locking using data snapshots. All together, these systems comprise one of the fundamental building blocks of modern relational databases, allowing them to safely process complex data in a crash-resistant manner.
Let Prisma Postgres handle the operations
Prisma Postgres provisions a fully managed Postgres database with connection pooling built in and daily backups on paid plans — so you can focus on your app, not the infrastructure.
Types of consistency failures
One reason people use transactions is to gain certain guarantees about the consistency of their data and the environment in which it is processed. Consistency can be broken in many different ways, which affects how databases attempt to prevent them.
There are four primary ways that inconsistency can arise depending on the transaction implementation. Your tolerance for scenarios where these scenarios may arise will affect how you use transactions in your applications.
Dirty reads
Dirty reads occur when the statements within a transaction are able to read data written by other in-progress transactions. This means that even though the statements of a transaction have not been committed yet, they can be read and thus influence other transactions.
This is often considered a severe breach of consistency, as transactions are not properly isolated form one another. Statements that may never be committed to the database can affect the execution of other transactions, modifying their behavior.
Transactions that allow dirty reads cannot make any reasonable claims about the consistency of the resulting data.
Non-repeatable reads
Nonrepeatable reads occur when a commit outside of the transaction alters the data seen within the transaction. You can recognize this type of problem if, within a transaction, the same data is read twice but different values are retrieved in each instance.
As with dirty reads, transactions that allow non-repeatable reads don't offer full isolation between transactions. The difference is that with non-repeatable reads, the statements affecting the transaction have actually been committed outside of the transaction.
Phantom read
A phantom read is a specific type of non-repeatable read that occurs when the rows returned by a query are different the second time it is executed within a transaction.
For instance, if a query within the transaction returns four rows the first time it is executed, but five rows the second time, this is a phantom read. Phantom reads are caused by commits outside of the transaction altering the number of rows that satisfy the query.
Serialization anomalies
Serialization anomalies occur when the results of multiple transactions committed concurrently will result in different outcomes than if they were committed one after another. This can occur any time that a transaction allows two commits to occur that each modify the same table or data without resolving conflicts.
Serialization anomalies are a special type of problem that early types of transactions had no understanding of. This is because early transactions were implemented with locking, where one could not continue if another transaction was reading from or altering the same piece of data.
Transaction isolation levels
Transactions are not a "one size fits all" solution. Different scenarios require different trade-offs between performance and protection. Fortunately, PostgreSQL allows you to specify the type of transaction isolation you need.
The isolation levels offered by most database systems include the following:
Read uncommitted
Read uncommitted is the isolation level that offers the fewest guarantees about maintaining data consistency and isolation. While transactions using read uncommitted have certain features frequently associated with transactions, like the ability to commit multiple statements at once or to roll back statements if a mistake occurs, they do allow numerous situations where consistency can be broken.
Transactions configured with the read uncommitted isolation level allow:
- dirty reads
- non-repeatable reads
- phantom reads
- serialization anomalies
This level of isolation is actually not implemented in PostgreSQL. Although PostgreSQL recognizes the isolation level name, internally, it is not actually supported and "read committed" (described below) will be used instead.
Read committed
Read committed is an isolation level that specifically protects against dirty reads. When transactions use the read committed level of consistency, uncommitted data can never affect the internal context of a transaction. This provides a basic level of consistency by ensuring that uncommitted data never influences a transaction.
Although read committed offers greater protection than read uncommitted, it does not protect against all types of inconsistency. These problems can still arise:
- non-repeatable reads
- phantom reads
- serialization anomalies
PostgreSQL will use the read committed level by default if no other isolation level is specified.
Repeatable read
The repeatable read isolation level builds off of the guarantee provided by read committed. It avoids dirty reads as before, but prevents non-repeatable reads as well.
This means that no changes committed outside of the transaction will ever impact the data read within the transaction. A query executed at the start of a transaction will never have a different result at the end of the transaction unless directly caused by statements within the transaction.
While the standard definition of the repeatable read isolation level requires only that dirty and non-repeatable reads are prevented, PostgreSQL also prevents phantom reads at this level. This means that commits outside of the transaction cannot alter the number of rows that satisfy a query.
Since the state of the data seen within the transaction can deviate from the up-to-date data in the database, a transaction fails with a serialization error if it tries to modify or lock a row that another transaction changed and committed after it started (ERROR: could not serialize access due to concurrent update). Because of this, one drawback of this isolation level is that you may have to retry transactions that fail this way.
PostgreSQL's repeatable read isolation level blocks most types of consistency issues but serialization anomalies can still occur.
Serializable
The serializable isolation level offers the highest level of isolation and consistency. It prevents all of the scenarios that the repeatable read level does while also removing the possibility of serialization anomalies.
Serializable isolation guarantees that concurrent transactions are committed as if they were executed one after another. If a scenario occurs where a serialization anomaly could be introduced, one of the transactions will have a serialization failure instead of introducing inconsistency to the data set.
Defining a transaction
Now that we've covered the different isolation levels that PostgreSQL can use in transactions, let's demonstrate how to define transactions.
In PostgreSQL, every statement outside of an explicitly marked transaction is actually executed in its own, single-statement transaction. To explicitly start a transaction block, you can use either the BEGIN or START TRANSACTION commands (they are synonymous). To commit a transaction, issue the COMMIT command.
The basic syntax of a transaction therefore looks like this:
As a more concrete example, imagine that we are attempting to transfer $1000 from one account to another. We want to ensure that the money will always be in one of the two accounts but never in both of them.
The examples in this guide use a simple accounts table. If you want to follow along, you can create it like this (the CHECK constraint prevents balances from going below zero):
We can wrap the two statements that together encapsulate this transfer in a transaction that looks like this:
Here, the $1000 will not be taken out of the account with id = 1 without also putting $1000 into the account with id = 2.
This is what atomicity means: the two changes succeed or fail as a unit. It doesn't mean that the statements run at the same time. Each UPDATE runs as soon as you send it, and the session running the transaction sees its own changes right away. For instance, after the first UPDATE, running SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id; inside the transaction returns:
If a second session runs the same query at that moment, it still sees the last committed state of both accounts:
Once the first session runs the second UPDATE and COMMIT, the second session sees both changes at once:
Rolling back transactions
Within a transaction, either all or none of the statements will be committed to the database. Abandoning the statements and modifications made within a transaction instead of applying them to the database is known as "rolling back" the transaction.
Transactions can be rolled back either automatically or manually. If a session disconnects before committing its transaction, PostgreSQL rolls the transaction back automatically.
Errors work differently. If a statement inside a transaction block fails, including with a serialization failure under the stricter isolation levels, PostgreSQL doesn't quietly undo the transaction and carry on. Instead, it marks the transaction as aborted and rejects every further command until you end the transaction block. For example, this transfer tries to take more money out of account 1 than it holds, which violates the table's CHECK constraint:
In psql, this prints:
The second UPDATE is rejected even though it would have succeeded on its own, and psql's default prompt shows a ! while the transaction is in this state (instead of the * it shows during a normal transaction). An aborted transaction can't be committed: COMMIT rolls it back instead, which is why the response to COMMIT above is ROLLBACK. None of the changes are saved. To end an aborted transaction, issue ROLLBACK (or, to keep the work done before the error, roll back to a save point, as described below).
To manually roll back statements that have been given during the current transaction, you can use the ROLLBACK command. This will cancel all of the statements within the transaction and end it, in essence turning back the clock to before the transaction started.
For instance, supposing we're using the same bank accounts example we were using before, if we find out after issuing the UPDATE statements that we accidentally transferred the wrong amount or used the wrong accounts, we could rollback the changes instead of committing them:
Once we ROLLBACK, the $1500 will still be in the account with id = 1.
Using save points when rolling back
The ROLLBACK command discards everything done since the BEGIN or START TRANSACTION command and ends the transaction. But what if we only want to revert some of the statements within the transaction?
While you cannot specify arbitrary places to roll back to when issuing ROLLBACK command, you can roll back to any "save points" that you've set throughout the transaction. You can mark places in your transaction ahead of time with the SAVEPOINT command and then reference those specific locations when you need to roll back.
These save points allow you to create an intermediate roll back point. You can then optionally revert any statements made between where you are currently and the save point and then continue working on your transaction.
To specify a save point, issue the SAVEPOINT command followed by a name for the save point:
To roll back to that save point, use the ROLLBACK TO SAVEPOINT command (the SAVEPOINT keyword is optional, so ROLLBACK TO save_1 works too):
Rolling back to a save point undoes the changes made after it but keeps the save point, so you can roll back to it again if you need to. When you no longer need a save point, you can remove it with RELEASE SAVEPOINT:
Releasing a save point keeps the changes made after it. Like everything else in the transaction, they're only made permanent when the transaction commits.
Let's continue the account-focused example we've been using:
Here, we're able to recover from a mistake we made without losing all of the work we've done in the transaction so far. After rolling back, we continue with the transaction as planned using the correct statements.
Recovering from errors with save points
Save points are also how you recover from an error without losing the whole transaction. As described above, a failed statement puts the transaction into an aborted state. Rolling back to a save point that was set before the failure discards the failed statement and makes the transaction usable again:
In psql, this prints:
The ROLLBACK line is the response to ROLLBACK TO SAVEPOINT, and this time COMMIT really commits: the transfer from account 1 to account 2 is saved, while account 3 is unchanged. If an application wraps a statement that might fail in a save point like this, it can decide what to do about the error without starting the whole transaction over.
Setting the isolation level of transactions
To set the level of isolation you'd like for a transaction, you can add an ISOLATION LEVEL clause to your START TRANSACTION or BEGIN command. The basic syntax looks like this:
The <isolation_level> can be any of these (described in detail earlier):
READ UNCOMMITTED(will result inREAD COMMITTEDsince this level isn't implemented in PostgreSQL)READ COMMITTEDREPEATABLE READSERIALIZABLE
The SET TRANSACTION command can also be used to set the isolation level after a transaction is started. However, you can only use SET TRANSACTION before any queries or data modifying commands are executed, so it doesn't allow for increased flexibility.
Chaining transactions
If you have multiple transactions that should be executed sequentially, you can optionally chain them together using the COMMIT AND CHAIN command.
The COMMIT AND CHAIN command completes the current transaction by committing the statements within. After the commit has been processed, it immediately opens a new transaction. This allows you to group another set of statements together in a transaction.
The statement works like COMMIT followed by BEGIN, except that the new transaction keeps the characteristics of the one that just ended, such as its isolation level:
Chaining transactions doesn't offer much in terms of new functionality, but it can be helpful for committing data at natural boundaries while continuing to focus on the same type of operations.
Conclusion
Transactions are not a silver bullet. There are a lot of trade offs that come with various isolation levels and understanding what types of consistency you need to protect can take thought and planning. This is especially true with long running transactions where the underlying data may change significantly and the possibility of conflict with other concurrent transactions increases.
That being said, the transaction mechanic offers a lot of flexibility and power. It goes a long way towards ensuring ACID guarantees are maintained even while performing interrelated, concurrent operations. Knowing when and how to properly use transactions to perform complex, safe operations is invaluable.
If you are using JavaScript or TypeScript, you can use Prisma to manage your PostgreSQL database. Prisma ORM can run a group of queries in a single database transaction. Unless told otherwise, it uses the isolation level configured in your database (READ COMMITTED by default in PostgreSQL). The transaction API, and whether you can request a different isolation level, differ between Prisma ORM versions, so check Prisma's transactions documentation for the version you use.
Create a managed Postgres database
Hosted Postgres with connection pooling and Query Insights on every plan, including Free — ready in seconds.
