PostgreSQL in Depth Module 2 – PostgreSQL data types, chosen well
Numbers, identity, UUIDv7 and generated columns
Store money exactly in PostgreSQL 18, use identity keys, compare uuidv7() with random UUIDs in a B-tree, and pick virtual or stored generated columns.
What you will learn
- Choose integer, bigint, numeric or double precision for counts, money and measurements
- Create keys with identity columns and explain why their sequences leave gaps
- Predict what widening an integer key to bigint locks and rewrites
- Compare random UUIDv4 keys with the uuidv7() keys of PostgreSQL 18 for index size and locality
- Decide between virtual generated columns, the default in PostgreSQL 18, and stored ones
Before you start
What you’ll learn · Before you start
What you will learn
- Choose integer, bigint, numeric or double precision for counts, money and measurements
- Create keys with identity columns and explain why their sequences leave gaps
- Predict what widening an integer key to bigint locks and rewrites
- Compare random UUIDv4 keys with the uuidv7() keys of PostgreSQL 18 for index size and locality
- Decide between virtual generated columns, the default in PostgreSQL 18, and stored ones
Before you start
On this page
Ten payments of 0.10 summed in double precision come to 0.9999999999999999, and a key declared as integer runs out at 2,147,483,647, which takes under 25 days at 1,000 inserts a second. Both choices are made on a schema’s first day and paid for years later, so this first lesson of the module covers the columns nearly every table has: numbers, keys and computed values.
You will measure each choice on recorded runs of PostgreSQL 18.6: exact and approximate sums, the money type under two locales, a gap in an identity column, the rewrite behind widening a key, UUIDv7 against random keys, and virtual against stored generated columns.
Numbers that must add up exactly belong in numeric
The first query of the session below adds 0.1 and 0.2. In double precision: PostgreSQL's 8-byte binary floating-point type, also called float8. It gives about 15 significant digits and fast arithmetic, but a decimal fraction such as 0.1 is kept as the nearest binary value, so sums of such values can drift., also called float8, the sum prints as 0.30000000000000004 and the comparison with 0.3 is false; in numeric: PostgreSQL's exact decimal type. numeric(p,s) keeps s digits after the point and p digits in all, rounds extra decimal places on input and refuses a value that does not fit; its arithmetic is exact but slower than that of integers or floats. the same comparison is true. Ten payments of 0.10 show what that means for money: 0.9999999999999999 in float8, exactly 1.0 in numeric.
Double precision and real are binary floating-point types, the IEEE 754 formats processors compute in. A decimal fraction such as 0.1 has no exact binary form, so the column keeps the nearest binary value and every sum carries the error forward. For measurements that is a good trade: 8 bytes, about 15 significant digits and fast arithmetic, for values that were approximate when a sensor read them. For amounts that must add up to the last cent it is the wrong one.
numeric stores decimal digits, so 0.1 is exactly 0.1 and sums, differences and products are exact. The manual recommends it for amounts of money and other quantities that must be exact, and warns that its arithmetic is much slower than that of integers or floats. A column declared numeric(10,2) keeps two digits after the point and ten in all: the run’s insert of 12.345 was rounded to 12.35, and 123456789.00 was refused with numeric field overflow, because ten digits with two after the point stay below 100,000,000. Four types cover most number columns:
| Type | Size | Exact | Good for |
|---|---|---|---|
integer |
4 bytes | yes, up to 2,147,483,647 | counts, small keys |
bigint |
8 bytes | yes, up to about 9.2 × 10¹⁸ | keys, amounts in minor units |
numeric(p,s) |
variable | yes, every digit kept | money, rates, quantities |
double precision |
8 bytes | no, about 15 digits | measurements, statistics |
Whole numbers only need the right size. integer stops at 2,147,483,647: the run’s 2147483647::int + 1 fails with integer out of range, while the same sum in bigint returns 2147483648. The session runs these queries and, at its end, the money type of the next section:
numbers_session.py
"""Exact and approximate numbers, integer limits and the money type, on a throwaway PostgreSQL 18.6 server."""
from pg_session import Server
# The session expects two errors: a value too wide for numeric(10,2) and an integer overflow.
with Server(settings={"autovacuum": "off", "timezone": "'UTC'"}) as pg:
pg.up()
pg.psql("numbers.sql", errors=2) Output
$ psql -X -a -f numbers.sql
-- The same sums in double precision (float8, binary and approximate) and in numeric (decimal and exact).
SELECT 0.1::float8 + 0.2::float8 AS float_sum,
0.1::float8 + 0.2::float8 = 0.3::float8 AS float_equal,
0.1::numeric + 0.2 = 0.3 AS numeric_equal;
float_sum | float_equal | numeric_equal
---------------------+-------------+---------------
0.30000000000000004 | f | t
(1 row)
-- Ten payments of 0.10 each.
SELECT sum(0.1::float8) AS float_total, sum(0.1::numeric) AS numeric_total
FROM generate_series(1, 10);
float_total | numeric_total
--------------------+---------------
0.9999999999999999 | 1.0
(1 row)
-- A tie: numeric rounds 2.5 away from zero, float8 to the nearest even number.
SELECT round(2.5::numeric) AS numeric_round, round(2.5::float8) AS float_round;
numeric_round | float_round
---------------+-------------
3 | 2
(1 row)
-- numeric(10,2) rounds to two places on the way in and refuses a value with too many digits.
CREATE TABLE price (amount numeric(10,2));
CREATE TABLE
INSERT INTO price VALUES (12.345), (99999999.99);
INSERT 0 2
INSERT INTO price VALUES (123456789.00);
psql:numbers.sql:13: ERROR: numeric field overflow
DETAIL: A field with precision 10, scale 2 must round to an absolute value less than 10^8.
SELECT amount FROM price;
amount
-------------
12.35
99999999.99
(2 rows)
-- integer stops at 2,147,483,647; bigint goes on to 9,223,372,036,854,775,807.
SELECT 2147483647::int + 1;
psql:numbers.sql:16: ERROR: integer out of range
SELECT 2147483647::bigint + 1 AS as_bigint;
as_bigint
------------
2147483648
(1 row)
-- money keeps a whole number of minor units and takes its scale and symbol from lc_monetary.
SHOW lc_monetary;
lc_monetary
-------------
en_US.UTF-8
(1 row)
CREATE TABLE wallet (balance money);
CREATE TABLE
INSERT INTO wallet VALUES (1234.5);
INSERT 0 1
SELECT balance FROM wallet;
balance
-----------
$1,234.50
(1 row)
SET lc_monetary = 'ja_JP.UTF-8';
SET
SELECT balance FROM wallet;
balance
-----------
¥123,450
(1 row)
RESET lc_monetary;
RESET
-- Whole minor units in a bigint (cents, or paise for rupees) are exact and need no locale.
CREATE TABLE wallet2 (balance_minor bigint NOT NULL);
CREATE TABLE
INSERT INTO wallet2 VALUES (123450);
INSERT 0 1
SELECT balance_minor, round(balance_minor / 100.0, 2) AS balance FROM wallet2;
balance_minor | balance
---------------+---------
123450 | 1234.50
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 numbers_session.py
numbers.sql
-- The same sums in double precision (float8, binary and approximate) and in numeric (decimal and exact).
SELECT 0.1::float8 + 0.2::float8 AS float_sum,
0.1::float8 + 0.2::float8 = 0.3::float8 AS float_equal,
0.1::numeric + 0.2 = 0.3 AS numeric_equal;
-- Ten payments of 0.10 each.
SELECT sum(0.1::float8) AS float_total, sum(0.1::numeric) AS numeric_total
FROM generate_series(1, 10);
-- A tie: numeric rounds 2.5 away from zero, float8 to the nearest even number.
SELECT round(2.5::numeric) AS numeric_round, round(2.5::float8) AS float_round;
-- numeric(10,2) rounds to two places on the way in and refuses a value with too many digits.
CREATE TABLE price (amount numeric(10,2));
INSERT INTO price VALUES (12.345), (99999999.99);
INSERT INTO price VALUES (123456789.00);
SELECT amount FROM price;
-- integer stops at 2,147,483,647; bigint goes on to 9,223,372,036,854,775,807.
SELECT 2147483647::int + 1;
SELECT 2147483647::bigint + 1 AS as_bigint;
-- money keeps a whole number of minor units and takes its scale and symbol from lc_monetary.
SHOW lc_monetary;
CREATE TABLE wallet (balance money);
INSERT INTO wallet VALUES (1234.5);
SELECT balance FROM wallet;
SET lc_monetary = 'ja_JP.UTF-8';
SELECT balance FROM wallet;
RESET lc_monetary;
-- Whole minor units in a bigint (cents, or paise for rupees) are exact and need no locale.
CREATE TABLE wallet2 (balance_minor bigint NOT NULL);
INSERT INTO wallet2 VALUES (123450);
SELECT balance_minor, round(balance_minor / 100.0, 2) AS balance FROM wallet2; Testing a float8 sum with =
A check such as sum(amount) = 1.00 on a float8 column fails whenever the binary errors do not cancel, as the ten payments above show. Rounding both sides hides the problem in one query and leaves it in the next; store the amounts as numeric, or as whole cents in a bigint, and the comparison is exact.
Rounding differs as well. round(2.5) gave 3 in numeric, which rounds a tie away from zero, and 2 in double precision, which on most machines rounds a tie to the nearest even number. Two reports that round the same amounts in different types can therefore disagree by a cent on every tie.
Key idea
Use numeric or integers for numbers that must add up exactly, and double precision for measurements, where speed and range matter and the last digit never did.
Checkpoint
Check: Exact and approximate numbers
The money type changes meaning with the locale
PostgreSQL also has a money type: A PostgreSQL type that stores a currency amount as a whole number of the currency's smallest units. The lc_monetary setting decides how many decimal places it shows and which symbol it prints, so the same value reads differently under another locale., and the end of the same session shows why it is rarely the right one. A balance of 1234.5 went into a money column and came back as $1,234.50 under the server’s lc_monetary setting, en_US.UTF-8. The session then set lc_monetary to ja_JP.UTF-8 and read the same row again: ¥123,450.
Nothing in the table changed. money keeps a whole number of the currency’s smallest units, here 123,450, and lc_monetary decides how many of them make one unit and which symbol to print. The Japanese locale formats yen with no decimal places, so the same stored number now reads as an amount a hundred times larger:
| lc_monetary | SELECT shows | What 123,450 counts |
|---|---|---|
| en_US.UTF-8 | $1,234.50 | cents of a dollar |
| ja_JP.UTF-8 | ¥123,450 | whole yen |
Money’s text form depends on the locale, so the manual warns that a dump may not load into a database whose lc_monetary differs. Dividing a money value by an integer also truncates toward zero instead of rounding.
Two exact choices need no locale at all. numeric(12,2) holds amounts up to 9,999,999,999.99 with the cents in place, and a currency code in its own column says what the digits are. A bigint of minor unit: The smallest unit of a currency, such as the cent of a dollar or the paisa of a rupee. Amounts kept as whole minor units in an integer column stay exact without a decimal type and are divided only for display. stores the run’s balance as 123450 and divides by 100 only for display, as the session’s last query does; its arithmetic is exact and fast.
Amounts in rupees
Keep rupee amounts as numeric(12,2) or as whole paise in a bigint: 50,000 rupees is 5,000,000 paise. A money column would print the same stored number with whatever symbol and decimal places the session’s lc_monetary names, which is a setting of the server and the session, not of the data.
Checkpoint
Check: The money type and the locale
Identity columns hand out numbers that are never taken back
An invoice table numbers its rows with id bigint GENERATED ALWAYS AS IDENTITY. In the next session Asha’s row took id 1. Ravi’s insert took 2 inside a transaction that then rolled back, an insert with no customer took 3 and failed its NOT NULL constraint, and Meera’s row took 4. The table holds ids 1 and 4, and no row was ever deleted.
An identity column: A column declared GENERATED ALWAYS AS IDENTITY or GENERATED BY DEFAULT AS IDENTITY, which takes its values from an implicit sequence. It is the SQL-standard form of an auto-numbered key; ALWAYS refuses a typed value unless the statement says OVERRIDING SYSTEM VALUE. is the SQL-standard way to number rows. PostgreSQL creates an implicit sequence: A database object that hands out increasing numbers through nextval. A number once handed out is never taken back, even when the transaction that took it rolls back, so the values it gives a table can have gaps. for it, here invoice_id_seq, and every insert that leaves the column out takes the sequence’s next value. The sequence never takes a value back. If it did, each insert would have to wait until the transactions holding lower numbers had committed or rolled back; handing every number out once lets many sessions insert at the same moment. The manual names rollbacks, crashes and INSERT ... ON CONFLICT, which computes its row before it meets the conflict, as causes of gaps, so a gap is the normal result and not a sign of lost data. Step through the four inserts and watch the sequence move on while rows disappear:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Asha's row takes 1. The INSERT leaves id out, so the identity column asks its sequence for the next value. It gets 1, and the row commits.
- Ravi's insert rolls back. Inside BEGIN the next INSERT takes 2 and writes Ravi's row. ROLLBACK removes the row, but the sequence stays at 2, because nextval is never undone.
- A failed insert takes 3. An INSERT with no customer takes 3 before the NOT NULL constraint refuses its row. The statement fails, and the 3 is used up as well.
- Meera's row gets 4. The next INSERT that succeeds takes 4. The table now holds ids 1 and 4, two gaps, and no row was ever deleted.
- Why numbers are not given back. If the sequence waited to learn whether each transaction commits, every insert would queue behind the slowest open one. Handing each number out once lets many sessions insert at the same moment, at the price of gaps.
ALWAYS is the stricter of the two forms. It refused a hand-typed id 2 with a hint to write OVERRIDING SYSTEM VALUE, and accepted the row once the statement said so; GENERATED BY DEFAULT AS IDENTITY would have taken the typed value without a word. Either form makes the column NOT NULL, but only a PRIMARY KEY or UNIQUE constraint makes its values unique. The run also reads the sequence from the system catalog: One of the tables in the pg_catalog schema where PostgreSQL records its objects, such as pg_class for relations, pg_attribute for columns and pg_constraint for constraints. They can be queried like any table. More in “System catalogs, pages and TOAST”: a bigint like its column, with a maximum of 9,223,372,036,854,775,807.
The older shorthand, serial: PostgreSQL shorthand, not a real type, for an integer column whose default calls nextval on a sequence the column owns; bigserial does the same with bigint. A value typed by hand goes around the default and leaves the sequence where it was., is not a real type: it creates an integer column whose default calls nextval on a sequence. A value typed by hand goes around that default and leaves the sequence where it was, so in the run the next default insert also took 1 and failed on the primary key. Its sequence is an integer, with a maximum of 2,147,483,647. The session runs every statement of this section:
identity_session.py
"""Identity columns, the gaps a sequence leaves, and serial, on a throwaway PostgreSQL 18.6 server."""
from pg_session import Server
# The session expects three errors: a NULL customer, a typed id that ALWAYS refuses, and serial's duplicate key.
with Server(settings={"autovacuum": "off", "timezone": "'UTC'"}) as pg:
pg.up()
pg.psql("identity.sql", errors=3) Output
$ psql -X -a -f identity.sql
-- An identity column: the SQL-standard way to number rows, backed by an implicit sequence.
CREATE TABLE invoice (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer text NOT NULL
);
CREATE TABLE
INSERT INTO invoice (customer) VALUES ('Asha') RETURNING id;
id
----
1
(1 row)
INSERT 0 1
-- A transaction takes the next number and then rolls back.
BEGIN;
BEGIN
INSERT INTO invoice (customer) VALUES ('Ravi') RETURNING id;
id
----
2
(1 row)
INSERT 0 1
ROLLBACK;
ROLLBACK
-- An insert that fails takes a number too.
INSERT INTO invoice (customer) VALUES (NULL);
psql:identity.sql:12: ERROR: null value in column "customer" of relation "invoice" violates not-null constraint
DETAIL: Failing row contains (3, null).
INSERT INTO invoice (customer) VALUES ('Meera') RETURNING id;
id
----
4
(1 row)
INSERT 0 1
SELECT id, customer FROM invoice ORDER BY id;
id | customer
----+----------
1 | Asha
4 | Meera
(2 rows)
-- ALWAYS refuses a value typed by hand, unless the statement says OVERRIDING SYSTEM VALUE.
INSERT INTO invoice (id, customer) VALUES (2, 'Kiran');
psql:identity.sql:16: ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
INSERT INTO invoice (id, customer) OVERRIDING SYSTEM VALUE VALUES (2, 'Kiran');
INSERT 0 1
-- The implicit sequence: its type and limits follow the column.
SELECT pg_get_serial_sequence('invoice', 'id') AS sequence_name;
sequence_name
-----------------------
public.invoice_id_seq
(1 row)
SELECT seqtypid::regtype AS type, seqstart, seqmax, seqcache
FROM pg_sequence WHERE seqrelid = pg_get_serial_sequence('invoice', 'id')::regclass;
type | seqstart | seqmax | seqcache
--------+----------+---------------------+----------
bigint | 1 | 9223372036854775807 | 1
(1 row)
-- serial, the older shorthand: an integer column with a default, which a typed value bypasses.
CREATE TABLE legacy (id serial PRIMARY KEY, note text);
CREATE TABLE
INSERT INTO legacy (id, note) VALUES (1, 'typed by hand');
INSERT 0 1
INSERT INTO legacy (note) VALUES ('from the sequence');
psql:identity.sql:25: ERROR: duplicate key value violates unique constraint "legacy_pkey"
DETAIL: Key (id)=(1) already exists.
SELECT seqtypid::regtype AS type, seqmax
FROM pg_sequence WHERE seqrelid = pg_get_serial_sequence('legacy', 'id')::regclass;
type | seqmax
---------+------------
integer | 2147483647
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 identity_session.py
identity.sql
-- An identity column: the SQL-standard way to number rows, backed by an implicit sequence.
CREATE TABLE invoice (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer text NOT NULL
);
INSERT INTO invoice (customer) VALUES ('Asha') RETURNING id;
-- A transaction takes the next number and then rolls back.
BEGIN;
INSERT INTO invoice (customer) VALUES ('Ravi') RETURNING id;
ROLLBACK;
-- An insert that fails takes a number too.
INSERT INTO invoice (customer) VALUES (NULL);
INSERT INTO invoice (customer) VALUES ('Meera') RETURNING id;
SELECT id, customer FROM invoice ORDER BY id;
-- ALWAYS refuses a value typed by hand, unless the statement says OVERRIDING SYSTEM VALUE.
INSERT INTO invoice (id, customer) VALUES (2, 'Kiran');
INSERT INTO invoice (id, customer) OVERRIDING SYSTEM VALUE VALUES (2, 'Kiran');
-- The implicit sequence: its type and limits follow the column.
SELECT pg_get_serial_sequence('invoice', 'id') AS sequence_name;
SELECT seqtypid::regtype AS type, seqstart, seqmax, seqcache
FROM pg_sequence WHERE seqrelid = pg_get_serial_sequence('invoice', 'id')::regclass;
-- serial, the older shorthand: an integer column with a default, which a typed value bypasses.
CREATE TABLE legacy (id serial PRIMARY KEY, note text);
INSERT INTO legacy (id, note) VALUES (1, 'typed by hand');
INSERT INTO legacy (note) VALUES ('from the sequence');
SELECT seqtypid::regtype AS type, seqmax
FROM pg_sequence WHERE seqrelid = pg_get_serial_sequence('legacy', 'id')::regclass; Invoice numbers taken from the id
An id used as an invoice number has a gap after every rollback. When the numbers must be consecutive, keep the identity as the row’s key and take the invoice number from a one-row counter table that the same transaction updates: its row lock makes concurrent invoices wait for one another, the cost the manual names for gapless numbering.
Checkpoint
Check: Identity columns and their gaps
Widening int to bigint later rewrites the whole table
An integer key works until its sequence nears 2,147,483,647, and the fix then costs far more than bigint would have cost on the first day. The next session creates two such keys, an identity column on event and a serial column on click, with 20,000 rows each, and widens both to bigint. Inside the transaction, pg_locks shows the ALTER TABLE holding an AccessExclusiveLock: The strongest table lock mode in PostgreSQL, taken by many forms of ALTER TABLE and by DROP TABLE, TRUNCATE and VACUUM FULL. It conflicts with every other mode, including the AccessShareLock that a plain SELECT holds. More in “Table locks, lock queues, deadlocks, timeouts” on event until COMMIT, and afterwards the table’s relfilenode: The number that names a table's or index's files on disk (pg_class.relfilenode). It often equals the relation's OID at first, but TRUNCATE, REINDEX and table rewrites give a relation a new one. More in “PostgreSQL architecture: processes, memory, files” is a new number: PostgreSQL wrote the whole table into a new file.
An integer takes 4 bytes and a bigint 8, so no row keeps its old shape. The manual says a type change normally rewrites the table and its indexes, and that ALTER TABLE takes ACCESS EXCLUSIVE unless a form says otherwise. That lock conflicts with every other mode, including the one a plain SELECT takes, so for the whole table rewrite: Copying every row of a table into a new file, building its indexes again and putting the new file in place of the old one, as most column type changes and VACUUM FULL do. The table gets a new relfilenode and is locked against all other queries meanwhile. nobody can read the table. Follow the statement through its stages:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The lock comes first. ALTER TABLE asks for an AccessExclusiveLock on event and keeps it until COMMIT. From here on every other query on the table waits, a plain SELECT included.
- Every row is written again. An integer takes 4 bytes and a bigint 8, so no row keeps its old shape. PostgreSQL writes each of the 20,000 rows into a new file, with the id widened.
- The indexes are built again. event_pkey pointed into the old file, so it is built again from the new one, as every other index of the table would be. On a large table this takes much of the time the lock is held.
- The new file takes over. The catalog now names the new file: the relfilenode changed, and the run printed event_rewritten = t for the comparison with the number saved before.
- COMMIT lets the queue in. Only COMMIT releases the lock, and the queries that waited all run. On 20,000 rows the wait is a moment; on hundreds of millions of rows it lasts as long as the copy and the index builds.
The ShareLock the run lists beside it adds nothing that the AccessExclusiveLock does not already cover. Twenty thousand rows take a moment; a table of a few hundred million rows keeps every query waiting for as long as the copy and the index builds last.
The last query compares the two keys afterwards. Both columns are bigint, but only the identity’s sequence was widened with its column: event’s sequence goes up to 9,223,372,036,854,775,807, while the serial sequence of click is still an integer that stops at 2,147,483,647, the same limit as before. ALTER SEQUENCE click_id_seq AS bigint fixes that, and raises the maximum because the old one was integer’s default.
widen_session.py
"""Changing an integer key to bigint later: the rewrite, its lock and the sequences, on PostgreSQL 18.6."""
from pg_session import Server
with Server(settings={"autovacuum": "off", "timezone": "'UTC'"}) as pg:
pg.up()
pg.psql("widen.sql") Output
$ psql -X -a -f widen.sql
-- Two keys that started as integer: an identity column and a serial column.
CREATE TABLE event (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kind text NOT NULL);
CREATE TABLE
CREATE TABLE click (id serial PRIMARY KEY, kind text NOT NULL);
CREATE TABLE
INSERT INTO event (kind) SELECT 'view' FROM generate_series(1, 20000);
INSERT 0 20000
INSERT INTO click (kind) SELECT 'view' FROM generate_series(1, 20000);
INSERT 0 20000
-- Remember the file event lives in (psql keeps the number in a variable and prints nothing).
SELECT relfilenode AS event_file FROM pg_class WHERE oid = 'event'::regclass \gset
-- Widening the column inside a transaction, to see the lock it holds until COMMIT.
BEGIN;
BEGIN
ALTER TABLE event ALTER COLUMN id TYPE bigint;
ALTER TABLE
SELECT mode, granted FROM pg_locks
WHERE locktype = 'relation' AND relation = 'event'::regclass AND pid = pg_backend_pid();
mode | granted
---------------------+---------
ShareLock | t
AccessExclusiveLock | t
(2 rows)
COMMIT;
COMMIT
SELECT relfilenode <> :event_file AS event_rewritten FROM pg_class WHERE oid = 'event'::regclass;
event_rewritten
-----------------
t
(1 row)
-- The identity's sequence was widened with the column; serial's sequence was not.
ALTER TABLE click ALTER COLUMN id TYPE bigint;
ALTER TABLE
SELECT c.relname AS table_name, format_type(a.atttypid, a.atttypmod) AS column_type,
s.seqtypid::regtype AS sequence_type, s.seqmax
FROM pg_class c
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'id'
JOIN pg_sequence s ON s.seqrelid = pg_get_serial_sequence(c.relname, 'id')::regclass
WHERE c.relname IN ('event', 'click') ORDER BY c.relname;
table_name | column_type | sequence_type | seqmax
------------+-------------+---------------+---------------------
click | bigint | integer | 2147483647
event | bigint | bigint | 9223372036854775807
(2 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 widen_session.py
widen.sql
-- Two keys that started as integer: an identity column and a serial column.
CREATE TABLE event (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kind text NOT NULL);
CREATE TABLE click (id serial PRIMARY KEY, kind text NOT NULL);
INSERT INTO event (kind) SELECT 'view' FROM generate_series(1, 20000);
INSERT INTO click (kind) SELECT 'view' FROM generate_series(1, 20000);
-- Remember the file event lives in (psql keeps the number in a variable and prints nothing).
SELECT relfilenode AS event_file FROM pg_class WHERE oid = 'event'::regclass \gset
-- Widening the column inside a transaction, to see the lock it holds until COMMIT.
BEGIN;
ALTER TABLE event ALTER COLUMN id TYPE bigint;
SELECT mode, granted FROM pg_locks
WHERE locktype = 'relation' AND relation = 'event'::regclass AND pid = pg_backend_pid();
COMMIT;
SELECT relfilenode <> :event_file AS event_rewritten FROM pg_class WHERE oid = 'event'::regclass;
-- The identity's sequence was widened with the column; serial's sequence was not.
ALTER TABLE click ALTER COLUMN id TYPE bigint;
SELECT c.relname AS table_name, format_type(a.atttypid, a.atttypmod) AS column_type,
s.seqtypid::regtype AS sequence_type, s.seqmax
FROM pg_class c
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'id'
JOIN pg_sequence s ON s.seqrelid = pg_get_serial_sequence(c.relname, 'id')::regclass
WHERE c.relname IN ('event', 'click') ORDER BY c.relname; Declare keys bigint on the first day
On a new table, bigint costs a few bytes a row; the rewrite above is what saving them costs later. On a table that is already large and busy, widen in steps instead, with a new bigint column filled in batches: the expand and contract: A way to change a column's type or name over several releases: add the new column, keep both columns in step, switch readers and writers to the new one, then drop the old one. More in “Zero-downtime schema migrations” pattern of zero-downtime schema migrations.
Checkpoint
Check: Widening an integer key to bigint
UUIDv7 keys arrive in order, random keys land anywhere
A UUID is a 128-bit identifier that any program can create without asking the database. The next session puts the same 25,000 rows under three keys: a bigint identity, a uuid filled by uuidv7(), and a uuid made from md5(n), which scatters keys over the range as random UUIDs do but repeats on every run. Then it reads each primary key index page by page with the pageinspect extension:
| Primary key | Index size | Leaf pages | Leaf space used |
|---|---|---|---|
| bigint identity | 568 kB | 69 | 89 % |
| uuidv7() | 784 kB | 96 | 90 % |
| scattered UUID | 1104 kB | 136 | 64 % |
A UUIDv4: A version 4 UUID: 122 random bits plus the bits that mark its version and variant. PostgreSQL makes one with gen_random_uuid() or, from version 18, uuidv4(); new keys of this kind land anywhere in an index. holds 122 random bits; gen_random_uuid() makes one, and PostgreSQL 18 adds the alias uuidv4(). A UUIDv7: A version 7 UUID as RFC 9562 defines it: the Unix time in milliseconds in its first 48 bits, then random bits, so keys made later sort later. PostgreSQL 18 makes one with uuidv7(), and uuid_extract_timestamp() reads its time back. starts with the Unix time in milliseconds in its first 48 bits, and the uuidv7() of PostgreSQL 18 follows it with a sub-millisecond fraction and random bits. Keys made later therefore sort later: in the run, none of the 25,000 UUIDv7 keys sorted below the key inserted just before it, against 12,509 of the scattered keys, about half, as chance predicts.
Where a key sorts decides which leaf page of the B-tree it is written to. Each new UUIDv7 key is the largest so far, so it goes to the rightmost leaf; when that page is full, PostgreSQL starts a new one, leaves the full page at the index’s fillfactor: The storage parameter, from 10 to 100 percent, that sets how full inserts pack each page of a table, 100 by default. The rest of every page is kept free for updated versions of its rows, which makes HOT updates more likely. More in “HOT updates, fillfactor, bloat and rebuilds”, 90 % by default, and new keys never go back to it. A scattered key goes to whichever leaf covers its value, and a full leaf in the middle splits: a page split: What a B-tree index does when a new entry does not fit a full page: it moves part of the page's entries to a new page and adds a link to that page in the parent. A split in the middle of an index leaves two partly empty pages. moves part of its entries to a new page and leaves both part empty. Step through the two indexes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The same rows, two keys. Both tables take 25,000 rows in the same order. One key comes from uuidv7(); the other from md5(n), which spreads keys over the range as uuidv4() does but repeats on every run.
- Each v7 key is the largest. A UUIDv7 starts with the time, so each new key sorts after every key before it: the run counted 0 keys below the one inserted just before them. Every insert goes to the rightmost leaf.
- Full leaves stay 90 % full. When the rightmost leaf is full, PostgreSQL starts a new one and leaves the full page at the fillfactor, 90 % by default. New keys never go back to an older leaf.
- Scattered keys land anywhere. 12,509 of the 25,000 scattered keys sorted below the key inserted just before them. Each one goes to the leaf whose range holds its value, anywhere in the index.
- Full leaves split in the middle. A full leaf in the middle splits: part of its entries move to a new page, and both are left part empty. Random keys keep splitting leaves all over the index, so it ends with 136 leaves, 64 % used.
- Where the newest rows went. The newest 1,000 UUIDv7 rows sit on 5 leaf pages; the newest 1,000 scattered rows are on all 136. A time-ordered key keeps a few pages busy, while a random key needs the whole index at hand.
The last two queries measure where the newest rows went. The 1,000 newest UUIDv7 rows sit on 5 leaf pages; the 1,000 newest scattered rows are spread over 136, every leaf of their index. A time-ordered key keeps a handful of pages busy, while a random key needs the whole index close at hand, and each page it touches may first have to be read from disk. Size follows from the same cause: at 64 % full the scattered index is 41 % larger than the UUIDv7 one, and both UUID indexes are larger than bigint’s because a UUID takes 16 bytes and a bigint 8.
uuid_session.py
"""bigint, uuidv7() and scattered UUID keys for 25,000 rows: index size, leaf fill and where new keys land."""
from pg_session import Server
# Autovacuum is off so that nothing but the session touches the indexes; the UTC time zone fixes v7_time's offset.
# This cluster is deleted when the run ends, so it skips flushing files to disk (initdb --no-sync, fsync off),
# which halves the run time and changes no number. Never switch fsync off on a server whose data you keep.
SETTINGS = {"autovacuum": "off", "timezone": "'UTC'", "fsync": "off"}
with Server(settings=SETTINGS, initdb=("--no-sync",)) as pg:
pg.up()
pg.psql("uuid.sql") Output
$ psql -X -a -f uuid.sql
-- pageinspect reads index pages; it is for superusers, on a test server like this one.
CREATE EXTENSION pageinspect;
CREATE EXTENSION
-- The same 25,000 rows under three keys; n is the order the rows arrive in.
CREATE TABLE k_bigint (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, n int);
CREATE TABLE
CREATE TABLE k_v7 (id uuid DEFAULT uuidv7() PRIMARY KEY, n int);
CREATE TABLE
CREATE TABLE k_random (id uuid PRIMARY KEY, n int);
CREATE TABLE
INSERT INTO k_bigint (n) SELECT g FROM generate_series(1, 25000) g;
INSERT 0 25000
INSERT INTO k_v7 (n) SELECT g FROM generate_series(1, 25000) g;
INSERT 0 25000
-- md5(n) scatters keys over the whole range as uuidv4() does, but gives the same keys on every run.
INSERT INTO k_random SELECT md5(g::text)::uuid, g FROM generate_series(1, 25000) g;
INSERT 0 25000
-- Row counts for the planner, so the queries on index pages below take well under a second.
ANALYZE k_bigint, k_v7, k_random;
ANALYZE
-- The version digit, and the time a version 7 UUID carries in its first 48 bits.
SELECT uuid_extract_version(uuidv4()) AS uuidv4, uuid_extract_version(gen_random_uuid()) AS gen_random_uuid,
uuid_extract_version(uuidv7()) AS uuidv7;
uuidv4 | gen_random_uuid | uuidv7
--------+-----------------+--------
4 | 4 | 7
(1 row)
SELECT uuid_extract_timestamp('01a39e94-0dc0-7000-8000-000000000001') AS v7_time,
uuid_extract_timestamp(uuidv4()) AS v4_time;
v7_time | v4_time
------------------------+---------
2027-02-10 09:30:00+00 |
(1 row)
-- How many keys sort below the key of the row inserted just before them?
SELECT (SELECT count(*) FROM (SELECT id < lag(id) OVER (ORDER BY n) AS back FROM k_v7) s WHERE back) AS v7,
(SELECT count(*) FROM (SELECT id < lag(id) OVER (ORDER BY n) AS back FROM k_random) s WHERE back) AS random;
v7 | random
----+--------
0 | 12509
(1 row)
-- Size, leaf pages and how full the leaf pages are, for each primary key index.
SELECT c.relname AS index, pg_size_pretty(pg_relation_size(c.oid)) AS size, count(*) AS leaf_pages,
round(100 * (1 - avg(s.free_size) / 8192)) AS leaf_used_pct
FROM pg_class c, bt_multi_page_stats(c.relname::text, 1, -1) s
WHERE c.relname IN ('k_bigint_pkey', 'k_v7_pkey', 'k_random_pkey') AND s.type = 'l'
GROUP BY c.oid, c.relname ORDER BY pg_relation_size(c.oid);
index | size | leaf_pages | leaf_used_pct
---------------+---------+------------+---------------
k_bigint_pkey | 568 kB | 69 | 89
k_v7_pkey | 784 kB | 96 | 90
k_random_pkey | 1104 kB | 136 | 64
(3 rows)
-- Which leaf pages hold the keys of the 1,000 newest rows (n > 24000)? htid is the row an entry points to.
SELECT count(DISTINCT b.blkno) AS v7_leaf_pages
FROM bt_multi_page_stats('k_v7_pkey', 1, -1) b, bt_page_items('k_v7_pkey', b.blkno) i
WHERE b.type = 'l' AND i.htid IN (SELECT ctid FROM k_v7 WHERE n > 24000);
v7_leaf_pages
---------------
5
(1 row)
SELECT count(DISTINCT b.blkno) AS random_leaf_pages
FROM bt_multi_page_stats('k_random_pkey', 1, -1) b, bt_page_items('k_random_pkey', b.blkno) i
WHERE b.type = 'l' AND i.htid IN (SELECT ctid FROM k_random WHERE n > 24000);
random_leaf_pages
-------------------
136
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 uuid_session.py
uuid.sql
-- pageinspect reads index pages; it is for superusers, on a test server like this one.
CREATE EXTENSION pageinspect;
-- The same 25,000 rows under three keys; n is the order the rows arrive in.
CREATE TABLE k_bigint (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, n int);
CREATE TABLE k_v7 (id uuid DEFAULT uuidv7() PRIMARY KEY, n int);
CREATE TABLE k_random (id uuid PRIMARY KEY, n int);
INSERT INTO k_bigint (n) SELECT g FROM generate_series(1, 25000) g;
INSERT INTO k_v7 (n) SELECT g FROM generate_series(1, 25000) g;
-- md5(n) scatters keys over the whole range as uuidv4() does, but gives the same keys on every run.
INSERT INTO k_random SELECT md5(g::text)::uuid, g FROM generate_series(1, 25000) g;
-- Row counts for the planner, so the queries on index pages below take well under a second.
ANALYZE k_bigint, k_v7, k_random;
-- The version digit, and the time a version 7 UUID carries in its first 48 bits.
SELECT uuid_extract_version(uuidv4()) AS uuidv4, uuid_extract_version(gen_random_uuid()) AS gen_random_uuid,
uuid_extract_version(uuidv7()) AS uuidv7;
SELECT uuid_extract_timestamp('01a39e94-0dc0-7000-8000-000000000001') AS v7_time,
uuid_extract_timestamp(uuidv4()) AS v4_time;
-- How many keys sort below the key of the row inserted just before them?
SELECT (SELECT count(*) FROM (SELECT id < lag(id) OVER (ORDER BY n) AS back FROM k_v7) s WHERE back) AS v7,
(SELECT count(*) FROM (SELECT id < lag(id) OVER (ORDER BY n) AS back FROM k_random) s WHERE back) AS random;
-- Size, leaf pages and how full the leaf pages are, for each primary key index.
SELECT c.relname AS index, pg_size_pretty(pg_relation_size(c.oid)) AS size, count(*) AS leaf_pages,
round(100 * (1 - avg(s.free_size) / 8192)) AS leaf_used_pct
FROM pg_class c, bt_multi_page_stats(c.relname::text, 1, -1) s
WHERE c.relname IN ('k_bigint_pkey', 'k_v7_pkey', 'k_random_pkey') AND s.type = 'l'
GROUP BY c.oid, c.relname ORDER BY pg_relation_size(c.oid);
-- Which leaf pages hold the keys of the 1,000 newest rows (n > 24000)? htid is the row an entry points to.
SELECT count(DISTINCT b.blkno) AS v7_leaf_pages
FROM bt_multi_page_stats('k_v7_pkey', 1, -1) b, bt_page_items('k_v7_pkey', b.blkno) i
WHERE b.type = 'l' AND i.htid IN (SELECT ctid FROM k_v7 WHERE n > 24000);
SELECT count(DISTINCT b.blkno) AS random_leaf_pages
FROM bt_multi_page_stats('k_random_pkey', 1, -1) b, bt_page_items('k_random_pkey', b.blkno) i
WHERE b.type = 'l' AND i.htid IN (SELECT ctid FROM k_random WHERE n > 24000); Two functions read the fields of a UUID. uuid_extract_version() returned 4 for uuidv4() and gen_random_uuid() and 7 for uuidv7(). uuid_extract_timestamp() read the time out of a fixed UUIDv7 literal, so that the run repeats, and returned NULL for a version 4 key. That is the price of time-ordered keys: anyone who sees a UUIDv7 can read when it was made. The UUID generator makes both versions and shows the time inside a version 7 key.
Which key fits depends on who creates the ids and what they may give away. Answer the questions for your own table:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Key | Index for 25,000 rows | Pages the newest rows reach | Made outside the database | What an id gives away |
|---|---|---|---|---|
| bigint identity Comes from: the table's own sequence | Good 568 kB with leaves 89 % used, for 8-byte keys | Good each new id is the largest, so inserts stay on the rightmost leaf | Poor only the database's sequence hands out the values | Mixed consecutive ids show the order of rows and roughly how many exist |
| UUIDv7 Comes from: uuidv7(), or any program | Mixed 784 kB with leaves 90 % used: 16-byte keys, packed full | Good the newest 1,000 rows sat on 5 leaf pages | Good any program can make one from its clock and random bits | Mixed uuid_extract_timestamp() reads back the millisecond time it carries |
| Random UUIDv4 Comes from: gen_random_uuid(), or any program | Poor 1104 kB with leaves 64 % used, after splits all over the index | Poor the newest 1,000 rows reached all 136 leaf pages | Good any program can make one with no coordination | Good random bits only, with no time and no order |
How the answers decide
- “Do programs outside the database create ids before their rows are inserted?” Yes, ids are made elsewhere: rules out bigint identity. An identity value comes from the database's sequence, so a row made offline or in another system has no id until it arrives.
- “May an id that users see show when its row was created, or how many rows exist?” No, ids must show nothing: rules out bigint identity and UUIDv7. Consecutive bigint ids show order and count, and uuid_extract_timestamp() reads the creation time out of a UUIDv7.
- “Will the table grow far beyond the memory of its server?” Yes, it will grow large: adds a caveat to Random UUIDv4. Random keys reached 136 leaf pages for the newest 1,000 rows against 5 for UUIDv7; in a large index most of those pages are not in memory.
In short: a bigint identity when only the database makes ids, a UUIDv7 when ids come from outside and may show their time, and a random UUID when an id must show nothing.
Key idea
Keys that grow with time, bigint identity or UUIDv7, fill each leaf page once and keep inserts on a few pages; random keys spread every insert over the whole index, which ends larger.
Checkpoint
Check: UUIDv7 and random keys in a B-tree
Generated columns are virtual by default, stored when you need an index
The orders table of the last session keeps an amount, an 18 % tax computed from it and a total. Both computed columns are generated column: A column computed by an immutable expression from other columns of the same row, which a statement cannot set. In PostgreSQL 18 it is virtual by default, computed when read, or STORED, computed when its row is written.: tax is declared with no keyword and total with STORED. After an UPDATE changed order 1’s amount to 1200.00, both followed on their own, with a tax of 216.00 and a total of 1416.00. An INSERT that tried to set the tax itself failed, because a generated column takes no value from a statement.
Generated columns come in two kinds, and PostgreSQL 18 makes the virtual kind the default. A virtual column has no bytes on disk: its expression runs each time a query reads it, and pg_attribute marks it with attgenerated v. A stored column is computed when its row is inserted or updated and kept in the row like any other value, marked s. Either kind may use only immutable functions and the columns of its own row, with no subqueries and no other generated column.
The session then tries the limits of the virtual kind. An index on tax fails with indexes on virtual generated columns are not supported, while the same index on the stored total is built. A virtual column whose expression calls with_tax(), a SQL function the session created, fails because virtual columns may call built-in functions only; the same expression is accepted as STORED. ANALYZE gathers no statistics for virtual columns, so pg_stats lists amount, gross, id and total but not tax, and extended statistics: Statistics that CREATE STATISTICS defines over several columns or over an expression, such as functional dependencies, combined distinct counts and lists of common combinations. ANALYZE fills them; they are not indexes. More in “Planner statistics and extended statistics” on tax are refused as well.
generated_session.py
"""Virtual and stored generated columns on PostgreSQL 18.6: what each computes, stores and refuses."""
from pg_session import Server
# The session expects four errors: a value for a generated column, an index on a virtual column, a virtual column
# that calls a user-defined function, and statistics on a virtual column.
with Server(settings={"autovacuum": "off", "timezone": "'UTC'"}) as pg:
pg.up()
pg.psql("generated.sql", errors=4) Output
$ psql -X -a -f generated.sql -- An 18 % tax as a virtual generated column (the default kind) and the total as a stored one. CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, amount numeric(12,2) NOT NULL, tax numeric(12,2) GENERATED ALWAYS AS (round(amount * 0.18, 2)), total numeric(12,2) GENERATED ALWAYS AS (amount + round(amount * 0.18, 2)) STORED ); CREATE TABLE INSERT INTO orders (amount) VALUES (1000.00), (249.99); INSERT 0 2 UPDATE orders SET amount = 1200.00 WHERE id = 1; UPDATE 1 SELECT * FROM orders ORDER BY id; id | amount | tax | total ----+---------+--------+--------- 1 | 1200.00 | 216.00 | 1416.00 2 | 249.99 | 45.00 | 294.99 (2 rows) -- attgenerated: v for virtual (computed when read), s for stored (computed when written). SELECT attname, attgenerated FROM pg_attribute WHERE attrelid = 'orders'::regclass AND attnum > 0 ORDER BY attnum; attname | attgenerated ---------+-------------- id | amount | tax | v total | s (4 rows) -- Neither kind takes a value from the statement. INSERT INTO orders (amount, tax) VALUES (10.00, 1.80); psql:generated.sql:15: ERROR: cannot insert a non-DEFAULT value into column "tax" DETAIL: Column "tax" is a generated column. -- An index needs the stored kind. CREATE INDEX orders_tax_idx ON orders (tax); psql:generated.sql:17: ERROR: indexes on virtual generated columns are not supported CREATE INDEX orders_total_idx ON orders (total); CREATE INDEX -- A virtual column may call built-in functions only; a stored one may call your own. CREATE FUNCTION with_tax(numeric) RETURNS numeric LANGUAGE sql IMMUTABLE RETURN round($1 * 1.18, 2); CREATE FUNCTION ALTER TABLE orders ADD COLUMN gross numeric GENERATED ALWAYS AS (with_tax(amount)) VIRTUAL; psql:generated.sql:21: ERROR: generation expression uses user-defined function DETAIL: Virtual generated columns that make use of user-defined functions are not yet supported. -- Adding a virtual column leaves the table's file alone; adding a stored one rewrites the table. SELECT relfilenode AS orders_file FROM pg_class WHERE oid = 'orders'::regclass \gset ALTER TABLE orders ADD COLUMN tax_cents bigint GENERATED ALWAYS AS (round(amount * 18)::bigint) VIRTUAL; ALTER TABLE SELECT relfilenode <> :orders_file AS rewritten_by_virtual FROM pg_class WHERE oid = 'orders'::regclass; rewritten_by_virtual ---------------------- f (1 row) ALTER TABLE orders ADD COLUMN gross numeric GENERATED ALWAYS AS (with_tax(amount)) STORED; ALTER TABLE SELECT relfilenode <> :orders_file AS rewritten_by_stored FROM pg_class WHERE oid = 'orders'::regclass; rewritten_by_stored --------------------- t (1 row) SELECT id, tax_cents, gross FROM orders ORDER BY id; id | tax_cents | gross ----+-----------+--------- 1 | 21600 | 1416.00 2 | 4500 | 294.99 (2 rows) -- ANALYZE skips virtual columns, and PostgreSQL 18 refuses extended statistics on them as well. ANALYZE orders; ANALYZE SELECT attname FROM pg_stats WHERE tablename = 'orders' ORDER BY attname; attname --------- amount gross id total (4 rows) CREATE STATISTICS orders_tax_stats ON (tax) FROM orders; psql:generated.sql:32: ERROR: statistics creation on virtual generated columns is not supported
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 generated_session.py
generated.sql
-- An 18 % tax as a virtual generated column (the default kind) and the total as a stored one.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount numeric(12,2) NOT NULL,
tax numeric(12,2) GENERATED ALWAYS AS (round(amount * 0.18, 2)),
total numeric(12,2) GENERATED ALWAYS AS (amount + round(amount * 0.18, 2)) STORED
);
INSERT INTO orders (amount) VALUES (1000.00), (249.99);
UPDATE orders SET amount = 1200.00 WHERE id = 1;
SELECT * FROM orders ORDER BY id;
-- attgenerated: v for virtual (computed when read), s for stored (computed when written).
SELECT attname, attgenerated FROM pg_attribute
WHERE attrelid = 'orders'::regclass AND attnum > 0 ORDER BY attnum;
-- Neither kind takes a value from the statement.
INSERT INTO orders (amount, tax) VALUES (10.00, 1.80);
-- An index needs the stored kind.
CREATE INDEX orders_tax_idx ON orders (tax);
CREATE INDEX orders_total_idx ON orders (total);
-- A virtual column may call built-in functions only; a stored one may call your own.
CREATE FUNCTION with_tax(numeric) RETURNS numeric LANGUAGE sql IMMUTABLE RETURN round($1 * 1.18, 2);
ALTER TABLE orders ADD COLUMN gross numeric GENERATED ALWAYS AS (with_tax(amount)) VIRTUAL;
-- Adding a virtual column leaves the table's file alone; adding a stored one rewrites the table.
SELECT relfilenode AS orders_file FROM pg_class WHERE oid = 'orders'::regclass \gset
ALTER TABLE orders ADD COLUMN tax_cents bigint GENERATED ALWAYS AS (round(amount * 18)::bigint) VIRTUAL;
SELECT relfilenode <> :orders_file AS rewritten_by_virtual FROM pg_class WHERE oid = 'orders'::regclass;
ALTER TABLE orders ADD COLUMN gross numeric GENERATED ALWAYS AS (with_tax(amount)) STORED;
SELECT relfilenode <> :orders_file AS rewritten_by_stored FROM pg_class WHERE oid = 'orders'::regclass;
SELECT id, tax_cents, gross FROM orders ORDER BY id;
-- ANALYZE skips virtual columns, and PostgreSQL 18 refuses extended statistics on them as well.
ANALYZE orders;
SELECT attname FROM pg_stats WHERE tablename = 'orders' ORDER BY attname;
CREATE STATISTICS orders_tax_stats ON (tax) FROM orders; Adding a column shows the other side of the choice. Adding the virtual tax_cents left the table’s file alone, while adding the stored gross gave the table a new relfilenode. The manual lists an added stored generated column, like an added identity column, among the changes that rewrite the table and its indexes, and says a virtual one never needs a rewrite.
PostgreSQL 19 and virtual columns
The release notes of PostgreSQL 19, still in preparation, allow extended statistics on virtual generated columns, so the CREATE STATISTICS that failed above is expected to work there. On 18, a column whose values the planner must estimate well is a reason to make it STORED.
Three questions decide the choice, against a third option: a plain column that the application fills in.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Column | An index on the value | A function of your own | Adding it to a full table | Planner statistics in 18 | Always equals its formula |
|---|---|---|---|---|---|
| Virtual generated Computed: when a query reads it | Poor refused: indexes on virtual generated columns are not supported | Poor refused: built-in functions only, so with_tax() failed | Good no rewrite: the relfilenode stayed the same | Poor ANALYZE skips it, and CREATE STATISTICS is refused | Good computed from its row on every read |
| Stored generated Computed: when its row is written | Good indexed like any column: orders_total_idx was built | Good may call an immutable function of your own, as gross did | Poor rewrites the table and its indexes, with a new relfilenode | Good ANALYZE gathers them as for any other column | Good computed again whenever its row is inserted or updated |
| Column the app fills Computed: by every program that writes | Good an ordinary column, indexed as usual | Good the program may compute it any way it likes | Mixed adding is quick, but filling it updates every row | Good ANALYZE gathers them as for any other column | Poor wrong whenever one writer forgets it or computes it differently |
How the answers decide
- “Will queries filter or sort on the computed value often enough to need an index?” Yes, it needs an index: rules out Virtual generated. PostgreSQL 18 refuses an index on a virtual generated column, as the CREATE INDEX on tax showed.
- “Does the formula call a function that you wrote?” Yes, a function of ours: rules out Virtual generated. A virtual column may use built-in functions only; with_tax() was refused for VIRTUAL and accepted for STORED.
- “Do several programs write rows to this table?” Yes, several programs: rules out Column the app fills. Every writer must compute the value the same way, and one that forgets leaves a row whose value no longer matches its formula.
- “Will queries filter or sort on the computed value often enough to need an index?” Yes, it needs an index: adds a caveat to Stored generated. Adding a stored column to a table that holds rows rewrites it under an AccessExclusiveLock, so plan it like a column type change.
Key idea
Leave a computed column virtual, the default, when it is only read; make it STORED when it needs an index, planner statistics or a function of your own, and accept the rewrite when you add it to a table that holds data.
Checkpoint
Check: Virtual and stored generated columns
Practise: fix a schema that stores money in float8
A billing table was written before anyone thought about types: id serial, amount float8, tax float8 and total float8, where the application computes the 18 % tax and the total itself, and one older job forgets the tax. Write down, column by column, what you would declare instead and why, then answer the questions. They mix all six sections and come back to what you missed.
Practice that adapts to you
Practise this lesson
20 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
One schema that meets every point: id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, amount numeric(12,2) NOT NULL, a virtual tax numeric(12,2) GENERATED ALWAYS AS (round(amount * 0.18, 2)), and total numeric(12,2) GENERATED ALWAYS AS (amount + round(amount * 0.18, 2)) STORED when reports search or sort by it.
Interview questions
Schema interviews often open with the gaps in an id column, then carry this page’s money and key choices into production: a ledger whose totals drift, or inserts that slow down as a table grows. The first question is answered right here; the other two open the module’s bank, which shows what each one tests and how a strong answer starts.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid · Warm-up
An identity column holds ids 1, 4 and 5, and no row was ever deleted. Where did 2 and 3 go, and when does that matter?
An identity column draws its values from a sequence, which never hands back a number once it has given it out. Every INSERT that leaves the column out calls
nextval; when its transaction rolls back, or the row fails a constraint, the row disappears but the number stays used. That is where 2 and 3 went. A crash andINSERT ... ON CONFLICT, which takes its number before it meets the conflict, leave gaps as well. The design is deliberate: a sequence that waited to learn whether each transaction commits would make every insert queue behind the slowest open one. Gaps matter only when a rule needs consecutive numbers, such as a numbering rule for invoices. Keep the identity as the row's key, and take those numbers from a counter row that the inserting transaction updates, which makes such inserts run one at a time. -
Mid to senior
A wallet service on PostgreSQL 18 has kept balances and 80 million transaction amounts in double precision for two years, and month-end totals now differ from the bank statement by a few paise. What do you change in the schema, how do you migrate the live tables, and what do you do about the stored amounts?
-
Mid to senior
An events table keyed by gen_random_uuid() takes 3,000 inserts a second. Past 200 million rows its insert latency and WAL volume keep climbing, while a table keyed by a bigint identity stays flat. Explain the cause, what you would measure to confirm it, and how you would change the key without stopping writes.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which numeric type should hold money, and why not double precision?
numeric keeps every decimal digit, so 0.1 + 0.2 = 0.3 and ten payments of 0.10 sum to 1.0. double precision drifts and suits measurements, not money. Store amounts as numeric or as minor units in a bigint.
-
Why can an identity column leave gaps in its ids?
Its sequence never takes a value back, so a rollback or a failed insert still consumes an id. Numbers that must be consecutive need a separate locked counter, not a sequence.
-
Why declare integer keys as bigint from the start?
Changing an int key to bigint rewrites the whole table under an AccessExclusiveLock, and a serial column's sequence stays an integer. Declaring keys bigint up front avoids a painful rewrite later.
-
How do uuidv7() keys compare with random UUIDs for index locality?
uuidv7() keys grow with time and fill leaf pages to about 90%: the newest 1,000 rows sat on 5 pages. Scattered (v4) keys touched all 136 leaves of an index 41% larger.
-
How do generated columns work in PostgreSQL 18?
They are virtual by default — computed when read and cheap to add. A generated column must be declared STORED before it can take an index, call a function of your own, or carry statistics.
Key takeaways
- numeric keeps every decimal digit, so 0.1 + 0.2 = 0.3 holds and ten payments of 0.10 sum to 1.0, while double precision drifts and suits measurements, not money.
- The money type stores minor units whose scale and symbol come from lc_monetary, so one row read $1,234.50 or ¥123,450; keep amounts as numeric or as minor units in a bigint.
- An identity column’s sequence never takes a value back, so a rollback and a failed insert left ids 1 and 4, and numbers that must be consecutive need a locked counter.
- Changing an integer key to bigint rewrote the table under an AccessExclusiveLock, and a serial column’s sequence stayed an integer, so declare keys bigint from the start.
- uuidv7() keys grow with time and fill leaf pages to 90 %: the newest 1,000 rows sat on 5 pages, while scattered keys reached all 136 leaves of an index 41 % larger.
- Generated columns are virtual by default in PostgreSQL 18, computed when read and cheap to add, and must be STORED to take an index, a function of your own or statistics.
Terms in this lesson
- double precision
- PostgreSQL's 8-byte binary floating-point type, also called float8. It gives about 15 significant digits and fast arithmetic, but a decimal fraction such as 0.1 is kept as the nearest binary value, so sums of such values can drift.
- generated column
- A column computed by an immutable expression from other columns of the same row, which a statement cannot set. In PostgreSQL 18 it is virtual by default, computed when read, or STORED, computed when its row is written.
- identity column
- A column declared GENERATED ALWAYS AS IDENTITY or GENERATED BY DEFAULT AS IDENTITY, which takes its values from an implicit sequence. It is the SQL-standard form of an auto-numbered key; ALWAYS refuses a typed value unless the statement says OVERRIDING SYSTEM VALUE.
- minor unit
- The smallest unit of a currency, such as the cent of a dollar or the paisa of a rupee. Amounts kept as whole minor units in an integer column stay exact without a decimal type and are divided only for display.
- money type
- A PostgreSQL type that stores a currency amount as a whole number of the currency's smallest units. The lc_monetary setting decides how many decimal places it shows and which symbol it prints, so the same value reads differently under another locale.
- numeric
- PostgreSQL's exact decimal type. numeric(p,s) keeps s digits after the point and p digits in all, rounds extra decimal places on input and refuses a value that does not fit; its arithmetic is exact but slower than that of integers or floats.
- page split
- What a B-tree index does when a new entry does not fit a full page: it moves part of the page's entries to a new page and adds a link to that page in the parent. A split in the middle of an index leaves two partly empty pages.
- sequence
- A database object that hands out increasing numbers through nextval. A number once handed out is never taken back, even when the transaction that took it rolls back, so the values it gives a table can have gaps.
- serial
- PostgreSQL shorthand, not a real type, for an integer column whose default calls nextval on a sequence the column owns; bigserial does the same with bigint. A value typed by hand goes around the default and leaves the sequence where it was.
- table rewrite
- Copying every row of a table into a new file, building its indexes again and putting the new file in place of the old one, as most column type changes and VACUUM FULL do. The table gets a new relfilenode and is locked against all other queries meanwhile.
- UUIDv4
- A version 4 UUID: 122 random bits plus the bits that mark its version and variant. PostgreSQL makes one with gen_random_uuid() or, from version 18, uuidv4(); new keys of this kind land anywhere in an index.
- UUIDv7
- A version 7 UUID as RFC 9562 defines it: the Unix time in milliseconds in its first 48 bits, then random bits, so keys made later sort later. PostgreSQL 18 makes one with uuidv7(), and uuid_extract_timestamp() reads its time back.
References
- PostgreSQL 18 documentation: 8.1 Numeric Types (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 8.2 Monetary Types (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 5.3 Identity Columns (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.17 Sequence Manipulation Functions (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE SEQUENCE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: ALTER SEQUENCE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: ALTER TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 13.3 Explicit Locking (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 8.12 UUID Type (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.14 UUID Functions (The PostgreSQL Global Development Group)
- RFC 9562: Universally Unique IDentifiers (UUIDs) (Internet Engineering Task Force (IETF))
- PostgreSQL 18 documentation: CREATE INDEX (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 65.1 B-Tree Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.23 pageinspect (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 5.4 Generated Columns (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 19 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 19 documentation: CREATE STATISTICS (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress