Skip to main content

Command Palette

Search for a command to run...

Tuple Layout

Updated
5 min read
Tuple Layout

When you insert a new row into a table, postgreSQL creates a tuple, a contiguous chunk of memory bytes that contains both system metadata and your actual data.

A tuple starts with a 23 byte header. Every single tuple in every table has this same header structure regardless of how many columns you have or which datatype you are using. These 23 bytes are pure overhead, which is why storing lots of tiny rows (say, a two-column table with a smallint and a boolean) is inefficient—you're spending more space on metadata than on data.

Tuple Headers

t_xmin

The first 4 bytes are t_xmin, a transaction ID. This is the ID of the transaction that created this tuple. When you run INSERT INTO users VALUES (1, 'Alice') inside transaction 1000, the resulting tuple gets t_xmin=1000.

This field never changes. Ever. Even if the tuple is later updated or deleted, t_xmin remains the ID of the transaction that originally created it. This is the foundation of MVCC. To determine if a tuple is visible to your transaction, PostgreSQL looks at t_xmin and asks: "Was this transaction committed before my snapshot was taken? Am I allowed to see tuples it created?"

t_xmax

The next 4 bytes are t_xmax, which is more complicated. In the simple case, if the tuple has never been deleted or locked, t_xmax is zero. But if a transaction deletes this tuple, t_xmax gets set to that transaction's ID.

Here's where it gets subtle: t_xmax can also mean "this tuple is locked" (as in SELECT ... FOR UPDATE) rather than deleted. How do you tell the difference? You have to look at the t_infomask flags. If HEAP_XMAX_LOCK_ONLY is set, then t_xmax is a lock, not a deletion. If HEAP_UPDATED is set, this was an UPDATE (so there's a newer version somewhere). If neither is set, it's a plain DELETE.

And there's yet another case: if multiple transactions lock the same tuple concurrently (for example, multiple SELECT ... FOR SHARE statements), t_xmax doesn't hold a transaction ID at all. Instead, it holds a "MultiXactId," which is an ID into a separate structure (pg_multixact) that stores a list of transaction IDs. The HEAP_XMAX_IS_MULTI flag in t_infomask tells you this has happened.

This overloading of t_xmax is a clever space optimization, but it makes the visibility logic quite complex.

t_cid

The next 6 bytes are t_ctid, a tuple identifier consisting of a page number (4 bytes) and a line pointer number (2 bytes). This field serves a dual purpose.

First, it's the tuple's own physical address, often called the TID (tuple identifier). If you run:

SELECT ctid, * FROM users;

You'll see values like (0,1), meaning page 0, line pointer 1. This is how indexes refer to tuples—they store the TID.

Second, t_ctid is used for update chains. When a tuple is the current version (hasn't been updated), its t_ctid points to itself: (0,1) points to (0,1). But when a tuple is updated, the old version's t_ctid gets changed to point to the new version. This creates a chain:

Old version at (0,1): t_ctid = (0,2)
New version at (0,2): t_ctid = (0,2)  [self-pointer]

If you update again:

Code

Old v1 at (0,1): t_ctid = (0,2)
Old v2 at (0,2): t_ctid = (0,3)
Current at (0,3): t_ctid = (0,3)

This chain allows PostgreSQL to follow updates. An index points to (0,1). When you look up that TID, you find an old version with t_ctid=(0,2), so you follow the chain to (0,2), then to (0,3), where you find the current version.

Long update chains are a performance problem. If a tuple has been updated 100 times, you have to follow 100 hops to reach the current version. This is one reason why HOT (Heap-Only Tuple) updates are so valuable—they keep the chain short and on the same page.

t_infomask

Now we come to the most complex field in the tuple header: t_infomask, a 2-byte bitmap containing 16 Boolean flags. This is where PostgreSQL packs a huge amount of state information.

Some flags describe the tuple's data layout. HEAP_HASNULL (bit 0) means at least one column is NULL, so there's a null bitmap after the tuple header. HEAP_HASVARWIDTH (bit 1) means there are variable-length columns. HEAP_HASEXTERNAL (bit 2) means at least one column is stored out-of-line in a TOAST table.

Other flags describe the tuple's MVCC state. HEAP_XMAX_LOCK_ONLY (bit 7) means t_xmax is a lock, not a delete. HEAP_UPDATED (bit 13) means this tuple was updated, so there's a newer version. HEAP_XMAX_IS_MULTI (bit 12) means t_xmax is a MultiXactId.

But the most important flags are the hint bits: HEAP_XMIN_COMMITTED (bit 8), HEAP_XMIN_INVALID (bit 9), HEAP_XMAX_COMMITTED (bit 10), and HEAP_XMAX_INVALID (bit 11). Understanding hint bits is essential.

Here's the problem they solve: To determine if a tuple is visible, we need to know whether t_xmin and t_xmax are committed or aborted. This information lives in the CLOG (commit log), also called pg_xact. The CLOG is on disk (or maybe cached in memory), and checking it requires I/O. If we had to check the CLOG for every tuple we examine during a query, performance would be terrible.

Hint bits cache this information directly in the tuple. The first time someone checks whether transaction 1000 is committed, they look it up in the CLOG. If it's committed, they set the HEAP_XMIN_COMMITTED bit in the tuple's t_infomask and mark the page dirty. From that point on, anyone who looks at this tuple sees the hint bit and knows immediately that transaction 1000 is committed, without having to touch the CLOG.

This has a fascinating consequence: a SELECT query can cause writes. If you run a big INSERT, creating millions of new tuples, and then immediately run a SELECT that scans the table, that SELECT will be the first to check the visibility of each tuple. For every tuple, it will look up the transaction in CLOG (probably finding it committed), set the hint bit, and mark the page dirty. Eventually, those dirty pages get written to disk. Your SELECT just triggered a write of the entire table.

The solution is to run VACUUM immediately after a bulk insert. VACUUM will proactively set all the hint bits, so subsequent queries won't have to.

CREATE TABLE hint_demo (id INT);
INSERT INTO hint_demo VALUES (1);

SELECT t_infomask FROM heap_page_items(get_raw_page('hint_demo', 0));

You might see t_infomask = 2818, which is 0x0B02 in hex. Let's decode that:

Binary: 0000 1011 0000 0010
Bit 1: HEAP_HASVARWIDTH (set)
Bit 8: HEAP_XMIN_COMMITTED (set)
Bit 9: HEAP_XMIN_INVALID (set)
Bit 11: HEAP_XMAX_INVALID (set)

Wait, both HEAP_XMIN_COMMITTED and HEAP_XMIN_INVALID are set? That seems contradictory. Actually, HEAP_XMIN_INVALID set means the tuple was created by an aborted transaction, which overrides the "committed" bit. This tuple is garbage and will never be visible to anyone.

t_infomask2

There's a second infomask field, t_infomask2, which is also 2 bytes. The lower 11 bits store the number of attributes (columns) in this tuple, allowing up to 2047 columns per table. The upper bits are flags related to HOT updates: HEAP_HOT_UPDATED (bit 14) and HEAP_ONLY_TUPLE (bit 13).

Demystifying Postgres

Part 2 of 5

Explore PostgreSQL internals in this series—learn how data is stored, queries run, and transactions work. Hands-on experiments and system-level insights help you master PostgreSQL like a backend engineer.

Up next

PostgreSQL Page Structure - (Slotted Pages)

From the previous blog we knew that tables are made up of 8KB pages, lets crack open a page and see what's really inside it. This is where the things get interesting because jargons like MVCC, tuple storage, visiblity rules, freespace is dependent on...