Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 12 – Concurrency control

MVCC and snapshot isolation inside PostgreSQL

How PostgreSQL keeps row versions with xmin and xmax, how a snapshot decides which version a query sees, what VACUUM removes, and why write skew slips through.

  • Advanced
  • 35 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 12 of 35 minutes free
Free: Row versions on a heap page · Snapshots and the visibility rule · What snapshot isolation prevents · Exercise. With a pass: 2 in-depth parts and 3 interview answers, about 23 minutes.

What you will learn

  • Explain snapshots and tuple visibility using xmin and xmax
  • Predict heap page contents after INSERT, UPDATE, DELETE and VACUUM
  • Compare PostgreSQL's in-heap versions with InnoDB's undo-log versions
  • Explain why snapshot isolation allows write skew

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain snapshots and tuple visibility using xmin and xmax
  • Predict heap page contents after INSERT, UPDATE, DELETE and VACUUM
  • Compare PostgreSQL's in-heap versions with InnoDB's undo-log versions
  • Explain why snapshot isolation allows write skew

Before you start

On this page

Multiversion concurrency control (MVCC) keeps several versions of each row, so that readers never wait for writers and writers never wait for readers. Every transaction reads from a snapshot, the set of changes that had committed at one moment, and picks out the row versions that belong to it. PostgreSQL stores the versions in the table itself and stamps each one with two transaction IDs, xmin for the transaction that created it and xmax for the one that deleted or replaced it. Snapshot isolation inside PostgreSQL rests on those two numbers: a transaction reads one snapshot for all its queries, and when two transactions write the same row, the first to commit wins. That prevents dirty reads, non-repeatable reads and phantoms, but it allows write skew, where two transactions read the same rows and then update different ones.

Row versions on a heap page

A table’s pages hold row versions, and each page has an array of line pointers that index entries point at (PostgreSQL 18: Database Page Layout). An INSERT writes a version with t_xmin set to its transaction. An UPDATE never changes a version in place: it writes a new version and stamps the old one’s t_xmax, and the old version’s t_ctid then points to the new one. A DELETE only stamps t_xmax. The model below follows three statements and prints the columns that the pageinspect extension’s heap_page_items shows (PostgreSQL 18: pageinspect):

A heap page after an insert, an update and a delete Python · heap_page.py
"""A PostgreSQL heap page in miniature. INSERT writes a row version stamped with its transaction ID in t_xmin;
UPDATE writes a new version and stamps the old one's t_xmax; DELETE only stamps t_xmax. t_ctid points from a
version to its newer version, or to itself. The page is printed with the columns of pageinspect's
heap_page_items. The transaction IDs are examples: a server uses whatever its counter has reached.
"""


class HeapPage:
    def __init__(self):
        self.items = []                                   # line pointers 1, 2, 3 ... in order

    def insert(self, xid, row):
        self.items.append({"t_xmin": xid, "t_xmax": 0, "t_ctid": len(self.items) + 1, "row": row})

    def latest(self, key):
        """The newest version of the row with this id: the one whose t_ctid points to itself."""
        for lp, item in enumerate(self.items, 1):
            if item["row"]["id"] == key and item["t_ctid"] == lp and item["t_xmax"] == 0:
                return lp, item
        raise KeyError(key)

    def update(self, xid, key, **changes):
        lp, old = self.latest(key)
        self.insert(xid, {**old["row"], **changes})
        old["t_xmax"], old["t_ctid"] = xid, len(self.items)

    def delete(self, xid, key):
        _, old = self.latest(key)
        old["t_xmax"] = xid

    def show(self, title):
        print(title)
        print(" lp | t_xmin | t_xmax | t_ctid | row")
        for lp, item in enumerate(self.items, 1):
            row = ", ".join(f"{k}={v}" for k, v in item["row"].items())
            print(f" {lp:2} | {item['t_xmin']:6} | {item['t_xmax']:6} | (0,{item['t_ctid']})  | {row}")


page = HeapPage()
page.insert(756, {"id": 1, "owner": "Asha", "balance": 500})
page.insert(756, {"id": 2, "owner": "Ravi", "balance": 300})
page.show("after INSERT by transaction 756")
page.update(757, 1, balance=450)
page.show("after UPDATE accounts SET balance = 450 WHERE id = 1, by transaction 757")
page.delete(758, 2)
page.show("after DELETE FROM accounts WHERE id = 2, by transaction 758")

Output

after INSERT by transaction 756
 lp | t_xmin | t_xmax | t_ctid | row
  1 |    756 |      0 | (0,1)  | id=1, owner=Asha, balance=500
  2 |    756 |      0 | (0,2)  | id=2, owner=Ravi, balance=300
after UPDATE accounts SET balance = 450 WHERE id = 1, by transaction 757
 lp | t_xmin | t_xmax | t_ctid | row
  1 |    756 |    757 | (0,3)  | id=1, owner=Asha, balance=500
  2 |    756 |      0 | (0,2)  | id=2, owner=Ravi, balance=300
  3 |    757 |      0 | (0,3)  | id=1, owner=Asha, balance=450
after DELETE FROM accounts WHERE id = 2, by transaction 758
 lp | t_xmin | t_xmax | t_ctid | row
  1 |    756 |    757 | (0,3)  | id=1, owner=Asha, balance=500
  2 |    756 |    758 | (0,2)  | id=2, owner=Ravi, balance=300
  3 |    757 |      0 | (0,3)  | id=1, owner=Asha, balance=450

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 heap_page.py

A heap page: the index points at line pointers 1 and 2; version 1 points to newer version 3; version 2 carries the xmax of its delete.Index on idid 1 at (0,1)id 2 at (0,2)Line pointer 1xmin 756, xmax 757Asha, 500: oldLine pointer 3xmin 757, xmax 0Asha, 450: currentLine pointer 2xmin 756, xmax 758Ravi, 300: deletedid 1id 2t_ctid (0,3)

Row versions on a PostgreSQL heap page after an update and a delete

Text description of the diagram

The index on id has two entries: id 1 points at line pointer 1 and id 2 at line pointer 2.

  1. Line pointer 1 holds Asha's old version: t_xmin 756 created it, t_xmax 757 replaced it, and its t_ctid (0,3) points to the newer version.
  2. Line pointer 3 holds Asha's current version, created by transaction 757, with t_xmax 0 because nothing has replaced it.
  3. Line pointer 2 holds Ravi's row, created by 756 and deleted by transaction 758, which only stamped its t_xmax.

A snapshot that does not see transaction 757 reads the version at line pointer 1; a later snapshot follows the t_ctid chain to line pointer 3.

After the update, the page holds two versions of Asha’s row. The old one, at line pointer 1, still says balance 500 and carries t_xmax 757; the new one, at line pointer 3, says 450. Ravi’s row was not removed by the delete, only stamped with 758. Which version a query gets depends on its snapshot.

Snapshots and the visibility rule

PostgreSQL writes a snapshot as xmin:xmax:xip_list: transactions below xmin had finished, those at or above xmax had not started yet, and xip_list names those still running in between (PostgreSQL 18: System Information Functions). A version is visible to a snapshot when its creator committed before the snapshot, or is the reading transaction itself, and its deleter, if it has one, did not commit before the snapshot:

Two readers, one uncommitted update, two isolation levels Python · snapshots.py
"""Which row version does each snapshot see? A snapshot is written xmin:xmax:xip_list, as PostgreSQL prints it:
transactions below xmin had finished, those at or above xmax had not started, and xip_list names the ones still
running in between. A version is visible when the transaction that created it (t_xmin) committed before the
snapshot, and the one that deleted or replaced it (t_xmax), if any, did not.
"""

versions = [                                  # Asha's row: two versions, as on the heap page
    {"t_xmin": 756, "t_xmax": 760, "balance": 450},
    {"t_xmin": 760, "t_xmax": 0, "balance": 400},
]
status = {756: "committed", 760: "in progress", 761: "committed"}   # 761 started after 760 and has finished


def finished_before(xid, snap):
    xmin, xmax, xip = snap
    return xid < xmin or (xid < xmax and xid not in xip)


def visible(v, snap, me=None):
    created = v["t_xmin"] == me or (status.get(v["t_xmin"]) == "committed" and finished_before(v["t_xmin"], snap))
    gone = v["t_xmax"] != 0 and (v["t_xmax"] == me or (status.get(v["t_xmax"]) == "committed"
                                                        and finished_before(v["t_xmax"], snap)))
    return created and not gone


def balance(snap, me=None):
    seen = [v["balance"] for v in versions if visible(v, snap, me)]
    return seen[0] if seen else None


def fmt(snap):
    return f"{snap[0]}:{snap[1]}:{','.join(map(str, snap[2]))}"


print("transaction 760 has changed Asha's balance from 450 to 400 and not committed yet;")
print("transaction 761, which started after it, has already committed")
print(f"  760 itself reads {balance((760, 762, [760]), me=760)}")
first = (760, 762, [760])
print(f"  R (REPEATABLE READ) and C (READ COMMITTED) both take snapshot {fmt(first)} and read {balance(first)}")
status[760] = "committed"
print("transaction 760 commits")
print(f"  R reads again with its snapshot {fmt(first)}: {balance(first)}")
second = (762, 762, [])
print(f"  C's next statement takes snapshot {fmt(second)} and reads {balance(second)}")

Output

transaction 760 has changed Asha's balance from 450 to 400 and not committed yet;
transaction 761, which started after it, has already committed
  760 itself reads 400
  R (REPEATABLE READ) and C (READ COMMITTED) both take snapshot 760:762:760 and read 450
transaction 760 commits
  R reads again with its snapshot 760:762:760: 450
  C's next statement takes snapshot 762:762: and reads 400

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 snapshots.py

Both readers saw 450 while 760 was uncommitted. After 760 committed, R still saw 450, because REPEATABLE READ keeps the snapshot of the transaction’s first statement for every query; C saw 400, because READ COMMITTED takes a new snapshot for each statement (PostgreSQL 18: Transaction Isolation). That is snapshot isolation inside PostgreSQL: its REPEATABLE READ level is snapshot isolation, as the manual says.

What snapshot isolation prevents

  • Dirty reads are prevented: uncommitted versions are never in another transaction’s snapshot.
  • Non-repeatable reads are prevented: every query reads the same snapshot.
  • Phantoms are prevented: rows inserted after the snapshot are invisible to it.
  • Lost updates are prevented: the second writer of a row fails if the first committed, “first committer wins”.
  • Write skew is allowed: two transactions that update different rows never collide.

Berenson and colleagues first defined snapshot isolation and showed that it is weaker than serializable (A Critique of ANSI SQL Isolation Levels). Write skew can be closed by locking the rows a transaction reads, by making the transactions update one shared row, or by running at SERIALIZABLE. The cost of versions is clean-up. VACUUM removes versions that no snapshot can see any more. A HOT update keeps a new version on the same page and skips index changes when no indexed column changes. Freezing marks old versions as visible to everyone, before 32-bit transaction IDs wrap around. MySQL’s InnoDB keeps the current row in place and rebuilds older versions from undo logs, which its purge removes (MySQL 8.4: InnoDB Multi-Versioning).

Exercise

Exercise · Medium · Python

Decide whether a row version is visible to a snapshot

Write visible(version, snapshot, status, me) in visibility.py, following the rule of the lesson.

  • version is a dictionary with "t_xmin", the transaction that created the version, and "t_xmax", the transaction that deleted or replaced it, or 0.
  • snapshot is a tuple (xmin, xmax, xip): transactions below xmin had finished, those at or above xmax had not started, and the list xip names those still running in between.
  • status maps a transaction ID to "committed", "aborted" or "in progress".
  • me is the reading transaction, whose own changes are visible to it.

A version is visible when its creator is me, or committed and finished before the snapshot, and its deleter, if there is one, is neither me nor a transaction that committed and finished before the snapshot. Start with the helper finished_before(xid, snapshot).

Starter code · visibility.py

"""Whether a row version is visible to a snapshot, by PostgreSQL's rules.

version: {"t_xmin": creator, "t_xmax": deleter or replacer, 0 if none}
snapshot: (xmin, xmax, xip)    status: {xid: "committed" | "aborted" | "in progress"}    me: the reader
"""


def finished_before(xid, snapshot):
    """Whether transaction xid had finished when the snapshot was taken."""
    return False


def visible(version, snapshot, status, me):
    return False
The sample tests · test_visibility.py
from visibility import visible

SNAP = (900, 905, [900, 903])


def test_own_uncommitted_insert():
    """A transaction sees its own insert before it commits"""
    assert visible({"t_xmin": 900, "t_xmax": 0}, SNAP, {900: "in progress"}, me=900) is True


def test_someone_elses_uncommitted_insert():
    """Another transaction's uncommitted insert is invisible"""
    assert visible({"t_xmin": 900, "t_xmax": 0}, SNAP, {900: "in progress"}, me=901) is False


def test_committed_before_the_snapshot():
    """A version committed before the snapshot is visible"""
    assert visible({"t_xmin": 890, "t_xmax": 0}, SNAP, {890: "committed"}, me=904) is True


def test_finished_between_xmin_and_xmax():
    """A creator between xmin and xmax that is not in the running list had finished: visible"""
    assert visible({"t_xmin": 901, "t_xmax": 0}, SNAP, {901: "committed"}, me=904) is True


def test_committed_after_the_snapshot():
    """A creator that was still running when the snapshot was taken stays invisible after it commits"""
    assert visible({"t_xmin": 903, "t_xmax": 0}, SNAP, {903: "committed"}, me=904) is False
    assert visible({"t_xmin": 906, "t_xmax": 0}, SNAP, {906: "committed"}, me=904) is False


def test_deleted_before_the_snapshot():
    """A version deleted by a transaction that committed before the snapshot is gone"""
    assert visible({"t_xmin": 890, "t_xmax": 899}, SNAP, {890: "committed", 899: "committed"}, me=904) is False


def test_delete_by_an_aborted_transaction():
    """An aborted delete does not hide the version"""
    assert visible({"t_xmin": 890, "t_xmax": 902}, SNAP, {890: "committed", 902: "aborted"}, me=904) is True


def test_delete_after_the_snapshot():
    """A delete that committed after the snapshot leaves the version visible to it"""
    assert visible({"t_xmin": 890, "t_xmax": 903}, SNAP, {890: "committed", 903: "committed"}, me=904) is True


def test_own_delete():
    """A transaction no longer sees a version it deleted itself"""
    assert visible({"t_xmin": 890, "t_xmax": 904}, SNAP, {890: "committed", 904: "in progress"}, me=904) is False
A hint

Write finished_before first: a transaction ID counts as finished before the snapshot when it is below xmin, or below xmax and not in xip. Then visible is two questions, one about t_xmin and one about t_xmax, each answered by the commit status together with finished_before. An aborted deleter never hides a version.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Transaction 757 runs UPDATE on Asha's row, whose version at line pointer 1 was created by 756. What does the heap page hold afterwards?

    Choose one answer.

    Show the answer to question 1

    Answer: The old version with t_xmax 757, and a new version with t_xmin 757

    An UPDATE never changes a version in place. It writes a new version stamped with its own transaction ID in t_xmin, stamps the old version's t_xmax with the same ID, and points the old version's t_ctid at the new one.

  2. Question 2 of 5 A REPEATABLE READ transaction reads a row, another transaction updates the row and commits, and the first transaction reads the row again. What does the second read return?

    Choose one answer.

    Show the answer to question 2

    Answer: The same value as the first read

    REPEATABLE READ keeps the snapshot of the transaction's first statement. The committing transaction is not in that snapshot, so the old version is still the visible one. A read never fails for this reason; only a write to the changed row would.

  3. Question 3 of 5 A snapshot is 760:762:760. Transaction 761 committed before the snapshot was taken. Is its change visible?

    Choose one answer.

    Show the answer to question 3

    Answer: Yes, because 761 is below xmax and not in the list of running transactions

    Transactions between xmin and xmax that are not in xip_list had finished when the snapshot was taken, so a committed change by 761 is visible. Transaction 760 is in the list, so its changes are not.

  4. Question 4 of 5 Which anomaly does snapshot isolation allow?

    Choose one answer.

    Show the answer to question 4

    Answer: Write skew

    Two transactions can read the same rows from their snapshots and then update different rows; neither write collides with the other, so both commit, even when together they break a rule that each one checked.

  5. Question 5 of 5 Where does MySQL's InnoDB keep the older versions of a row?

    Choose one answer.

    Show the answer to question 5

    Answer: In undo logs, from which it rebuilds older versions when a snapshot needs them

    InnoDB updates the current row in place and writes an undo record, and a consistent read that needs an older version rebuilds it from those records. Purge removes undo records that no snapshot needs any more.

Inside the heap: HOT chains, the vacuum horizon and freezing, with numbers Pro: opens with a Pro, Premium or Ultimate pass

Versions cost space until somebody removes them, and PostgreSQL may remove a version only when no snapshot can need it. This part reproduces what one long transaction does to that clean-up, shows how to find the culprit, and works out how close a busy database gets to transaction ID wraparound.

In this part:

  • HOT chains and the redirect left behind
  • What VACUUM may remove: the horizon, reproduced
  • The visibility map, and why an old snapshot also slows index-only scans
  • Finding what holds the horizon back
  • Freezing and wraparound, with numbers

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Write skew, its fixes, and how InnoDB keeps versions instead Pro: opens with a Pro, Premium or Ultimate pass

Snapshot isolation has one gap that matters in practice, write skew. This part reproduces it, fixes it two ways, runs the same rule on a real engine that refuses the stale writer, and compares PostgreSQL’s version store with InnoDB’s.

In this part:

  • Write skew reproduced, and two fixes
  • The same rule on a real engine that refuses the stale writer
  • Two ways to keep versions: PostgreSQL’s heap and InnoDB’s undo log

2 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What do xmin and xmax mean on a PostgreSQL row version?

    Every row version in a PostgreSQL table carries two transaction IDs in its header. xmin is the transaction that created the version, with an INSERT or an UPDATE. xmax is the transaction that deleted it or replaced it with a newer version, or 0 while nothing has. An UPDATE writes a new version with its own ID in xmin and puts the same ID into the old version's xmax, so both versions stay in the table for a while. A snapshot uses the two numbers to decide what a query sees: a version is visible when its xmin committed before the snapshot and its xmax, if it has one, did not. Queries can read them as the system columns xmin and xmax, and pageinspect shows them as t_xmin and t_xmax.

  2. Mid to senior

    A PostgreSQL table holds a steady two million rows, yet over a week it grows from 2 GB to 40 GB on disk, and autovacuum runs regularly. What do you suspect, how do you confirm it, and how do you fix it and get the space back?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    Your PostgreSQL logs warn that a database must be vacuumed within 10 million transactions. Explain what is happening, how much time you have, and what you do now and afterwards.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    An update-heavy table also serves hour-long analytical queries. Compare what this costs on PostgreSQL and on MySQL's InnoDB, given how each stores old row versions, and what you would tune on each.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • MVCC keeps several versions of each row so that reads and writes do not block each other; PostgreSQL stamps each version with xmin and xmax and chains old versions to new ones through t_ctid.
  • A snapshot xmin:xmax:xip_list decides which versions a query sees: REPEATABLE READ keeps one snapshot per transaction, READ COMMITTED takes one per statement.
  • Snapshot isolation prevents dirty reads, non-repeatable reads, phantoms and lost updates, but allows write skew.
  • VACUUM, HOT updates and freezing keep PostgreSQL’s versions in check; InnoDB keeps old versions in undo logs.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.