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.

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.

  • Beginner
  • 20 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

float8 against numeric, integer limits and the money type

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

  1. Question 1 of 2 A report adds ten payments of 0.10 stored as float8 and tests the result with = 1.0. What does the test return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: false: the float sum comes to 0.9999999999999999

    Each 0.10 becomes the nearest double precision value, slightly off from one tenth. Summed ten times, the errors do not cancel: the run printed 0.9999999999999999 for float8 and exactly 1.0 for numeric, so an equality test on the float sum is false.

  2. Question 2 of 2 A column is declared numeric(8,2). Which of these inserts fails?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1234567.00

    numeric(p,s) keeps s digits after the point and p in all, so p − s digits remain before it. The run's numeric(10,2) refused 123456789.00 for the same reason, with numeric field overflow.

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

  1. Question 1 of 2 A money column shows $250.00 under lc_monetary en_US.UTF-8. A session sets lc_monetary to ja_JP.UTF-8 and selects the same row. What does it show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ¥25,000

    The run stored 1234.5, read it as $1,234.50 and then as ¥123,450: the number of smallest units stays, and lc_monetary decides how many of them make one unit and which symbol to print.

  2. Question 2 of 2 A wallet balance of 7,305.25 dollars is kept as a bigint of cents. What integer is stored?

    Type a number.

    A hint

    Multiply by the number of minor units in one dollar.

    Show the answer: Question 2 of 2

    Answer: 730525 cents

    7,305.25 × 100 = 730,525 cents. Dividing by 100 for display gives 7305.25 again, exactly, as the run turned 123450 back into 1234.50.

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:

Interactive · step by step Four inserts and the numbers they take Step through the inserts of the identity session and watch the sequence move on while two rows never arrive.
Four inserts and the numbers they take, step 5 of 5. Showing Your session, invoice_id_seq (last value 4), invoice table (rows: 1 and 4), Numbers lost (2 and 3). In focus: invoice_id_seq.nextvalINSERTYour sessioninvoice_id_seqlast value 4invoice tablerows: 1 and 4Numbers lost2 and 3

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 values, a rollback gap, ALWAYS and serial

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

  1. Question 1 of 2 An identity column has handed out 1 to 10. A transaction inserts three rows and rolls back, then a single-row INSERT takes its value and fails a CHECK constraint. Which id does the next successful single-row INSERT get?

    Type a number.

    A hint

    Count every value that nextval handed out, whether or not its row survived.

    Show the answer: Question 1 of 2

    Answer: 15 id

    The rolled-back transaction took 11, 12 and 13 and the failed insert took 14. nextval is never undone, so the next value is 15, just as Meera's row got 4 in the run after 2 and 3 were lost.

  2. Question 2 of 2 id is GENERATED ALWAYS AS IDENTITY. What does INSERT INTO invoice (id, customer) VALUES (2, 'Kiran') do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails, unless the statement adds OVERRIDING SYSTEM VALUE

    The run shows the error cannot insert a non-DEFAULT value into column "id", with the hint Use OVERRIDING SYSTEM VALUE to override, and then the same row accepted once the statement said so.

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:

Interactive · stage by stage What ALTER COLUMN id TYPE bigint does to event Step through the statement, from the lock it takes to the COMMIT that lets the waiting queries in.
What ALTER COLUMN id TYPE bigint does to event, step 5 of 5. Showing AccessExclusiveLock, Write a new file, Build the indexes again, New relfilenode, COMMIT. In focus: COMMIT.AccessExclusiveLockevery other query waitsWrite a new fileeach row with a bigint idBuild the indexes againevent_pkey from the new fileNew relfilenodepg_class names the fileCOMMITthe lock is released

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

int to bigint: the lock, the new file and the two sequences

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

  1. Question 1 of 2 ALTER TABLE event ALTER COLUMN id TYPE bigint runs on a large table. What can another session do with event until that transaction commits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, not even a SELECT: every query waits for the AccessExclusiveLock

    The run lists an AccessExclusiveLock for the ALTER TABLE inside its transaction and a new relfilenode after COMMIT: the table was copied, and the lock kept every other session out until the copy was in place.

  2. Question 2 of 2 click.id was a serial column and has just been changed to bigint. What is the maximum of its sequence now?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2,147,483,647, because the sequence is still an integer

    The last query of the widen session shows click as bigint with an integer sequence and a seqmax of 2147483647, and event as bigint with a bigint sequence. The serial column would still run out of numbers at the same row as before.

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:

Interactive · step by step Where new keys land in two B-tree indexes Step through 25,000 inserts into the uuidv7() index and the scattered one, then see where the newest 1,000 rows went.
Where new keys land in two B-tree indexes, step 6 of 6. Showing uuidv7() keys (time first), Scattered keys (md5(n) as uuid), k_v7_pkey (newest: 5 pages), k_random_pkey (newest: 136 pages). In focus: k_v7_pkey, k_random_pkey.rightmost leafany leafuuidv7() keystime firstScattered keysmd5(n) as uuidk_v7_pkeynewest: 5 pagesk_random_pkeynewest: 136 pages

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

bigint, uuidv7() and scattered UUID keys, page by page

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:

Interactive · answer and compare Which primary key fits your table? Answer three questions about who creates the ids and what they may show; the table marks the keys that fit.
Do programs outside the database create ids before their rows are inserted?
May an id that users see show when its row was created, or how many rows exist?
Will the table grow far beyond the memory of its server?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three primary keys for the same rows, against the run's index numbers and what each id gives away.
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

  1. Question 1 of 2 In the run, how many leaf pages held the keys of the 1,000 newest rows?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 5 for uuidv7(), and 136 for the scattered keys, every leaf of that index

    The last two queries of the uuid session count the distinct leaf pages that hold the 1,000 newest keys: 5 for uuidv7() and 136 for the md5 keys, which spread over the range as uuidv4() keys do.

  2. Question 2 of 2 What does uuid_extract_timestamp() return for a key from gen_random_uuid()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: NULL, because a version 4 UUID carries no time

    uuid_extract_timestamp() reads the time of versions 1 and 7 and returns NULL for the others; uuid_extract_version() returned 4 for both uuidv4() and gen_random_uuid() in the run.

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.

Virtual and stored generated columns, and what each refuses

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.

Interactive · answer and compare Virtual, stored or a column the application fills? Answer three questions about the computed value; the table marks which way of keeping it fits.
Will queries filter or sort on the computed value often enough to need an index?
Does the formula call a function that you wrote?
Do several programs write rows to this table?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to keep a value computed from other columns, against what the generated session showed.
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

  1. Question 1 of 2 On PostgreSQL 18, orders.tax is declared numeric(12,2) GENERATED ALWAYS AS (round(amount * 0.18, 2)) with no other keyword. What does CREATE INDEX ON orders (tax) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It fails: with no keyword the column is virtual, and virtual columns cannot be indexed

    A generated column with neither keyword is VIRTUAL in PostgreSQL 18. The run's CREATE INDEX on tax failed, and the same statement on the STORED total succeeded.

  2. Question 2 of 2 Which of these ADD COLUMN changes make PostgreSQL 18 rewrite a table that already holds rows?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • ADD COLUMN gross numeric GENERATED ALWAYS AS (amount * 1.18) STORED
    • ADD COLUMN n bigint GENERATED ALWAYS AS IDENTITY

    The manual lists an added stored generated column, an identity column and a volatile default among the changes that rewrite the table and its indexes, and says that adding a virtual generated column never does. The run shows both generated cases through relfilenode.

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.

  1. Question 1 of 20 In which type is 0.1 + 0.2 = 0.3 true?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: numeric

    Binary floating-point types keep the nearest binary value of each decimal fraction, so the float8 sum was 0.30000000000000004. numeric keeps the digits themselves, and its comparison was true.

  2. Question 2 of 20 What does SELECT 2147483647::int + 1 return?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: ERROR: integer out of range

    The run printed integer out of range for the integer sum and 2147483648 for the same sum cast to bigint, whose limit is about 9.2 × 10¹⁸.

  3. Question 3 of 20 What do round(2.5::numeric) and round(2.5::float8) return?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: 3 and 2

    The run printed numeric_round 3 and float_round 2. Rounding the same amounts in different types can therefore move a total by a cent on every tie.

  4. Question 4 of 20 A table receives 50 million temperature readings a day, each given to one decimal place by sensors accurate to about half a degree, and dashboards average them. Which type fits the readings best?

    Choose one answer.

    Show the answer: Question 4 of 20

    Answer: double precision

    Exactness matters for numbers that must add up, such as money. Measurements carry their own error, so double precision is the fast, compact fit; numeric would be exact about digits that were never exact.

  5. Question 5 of 20 Where does a money column get its number of decimal places?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: From the lc_monetary setting

    money stores a whole number of smallest units, and lc_monetary says how many of them make one unit and which symbol to print, so the run showed $1,234.50 and then ¥123,450 for one stored value.

  6. Question 6 of 20 A dump with a money column, made where lc_monetary is en_US.UTF-8, is restored into a database whose lc_monetary is ja_JP.UTF-8. What does the manual warn about?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: The load may not work, because money is dumped as text formatted for the old locale

    money reads and writes its text form through lc_monetary, so a dump made under one locale may not load under another. numeric or a bigint of minor units restores the same digits anywhere.

  7. Question 7 of 20 An app keeps prices in US dollars, which have cents, and in Japanese yen, which have none, in one table. Which design keeps every amount exact and readable on any server?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: A bigint amount in each currency's minor units, with a currency code column

    An amount needs exact digits and a stated currency: a bigint of minor units or a numeric amount, each beside a currency code. The money type puts the meaning in a setting instead of in the row.

  8. Question 8 of 20 Why can an identity column not promise numbers without gaps?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: nextval is never undone, so a value taken by a transaction that rolls back is lost

    The manual says the value nextval returns is not reclaimed when the transaction aborts, so rollbacks, failed inserts, crashes and ON CONFLICT all leave gaps. The run's table kept ids 1 and 4.

  9. Question 9 of 20 A serial table gets INSERT INTO legacy (id, note) VALUES (1, 'typed by hand') and then INSERT INTO legacy (note) VALUES ('from the sequence'). Why does the second insert fail?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: The typed id did not move the sequence, so its first nextval was also 1 and hit the primary key

    The run shows duplicate key value violates unique constraint "legacy_pkey" with Key (id)=(1) already exists. GENERATED ALWAYS AS IDENTITY would have refused the typed value in the first place.

  10. Question 10 of 20 A rule says invoice numbers must run 1, 2, 3 with no gaps, while orders arrive from many sessions at once. Which design meets it?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: An identity key for the row, and the invoice number from a counter row that each inserting transaction updates, and so locks

    Sequences cannot give gapless numbers because nextval is never rolled back. A counter row updated in the same transaction can, and the manual warns that it is much more expensive: each invoice waits for the one before it to commit.

  11. Question 11 of 20 After ALTER COLUMN id TYPE bigint, the run compares the relfilenode of event with the one it saved before and prints event_rewritten = t. What does that show?

    Choose one answer.

    Show the answer: Question 11 of 20

    Answer: The table was copied into a new file: a full rewrite

    relfilenode names the file a table lives in. Changing a column from integer to bigint changes the layout of every row, so the run's table got a new file.

  12. Question 12 of 20 An integer identity column starts at 1 and takes 500 new rows a second, day and night. About how many days go by before it reaches 2,147,483,647?

    Type a number.

    A hint

    Divide the limit by 500 rows, then by the 86,400 seconds of one day.

    Show the answer: Question 12 of 20

    Answer: 50 days (anything from 49 to 51 counts)

    2,147,483,647 ÷ 500 ≈ 4,294,967 seconds, and ÷ 86,400 seconds a day that is about 49.7 days. Gaps from rollbacks only bring the day closer.

  13. Question 13 of 20 A 400 GB orders table on a busy server has an integer key that has reached 1.9 billion. Which plan widens it without closing the shop for hours?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: Add a bigint column, fill it in batches, keep it in step, then switch the key to it

    A type change rewrites the whole table under ACCESS EXCLUSIVE, as the run's new relfilenode showed. On a large table the expand and contract steps of zero-downtime schema migrations spread the work over many short transactions.

  14. Question 14 of 20 What does a UUIDv7 hold in its first 48 bits?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: The Unix time in milliseconds

    RFC 9562 puts a Unix timestamp in milliseconds in the most significant 48 bits of a UUIDv7, and the uuidv7() of PostgreSQL 18 follows it with a sub-millisecond fraction and random bits.

  15. Question 15 of 20 Why were the leaf pages of the scattered keys 64 % full on average, while those of the uuidv7() keys were 90 % full?

    Choose one answer.

    Show the answer: Question 15 of 20

    Answer: Inserts in the middle split full pages and leave room behind; inserts at the right edge fill each page to the fillfactor and move on

    A full leaf in the middle of the index splits and leaves two part-full pages. A key that is always the largest goes to the rightmost leaf, which PostgreSQL fills to the fillfactor of 90 % before it starts the next.

  16. Question 16 of 20 A mobile app creates orders while offline and syncs them later. Order ids appear in links, and showing when an order was created is acceptable. Which key fits best?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: A version 7 UUID made on the device

    Ids made outside the database rule out a sequence, and a UUID needs no coordination. Of the two versions, UUIDv7 keeps index locality, and the creation time it shows is allowed here.

  17. Question 17 of 20 What does pg_attribute.attgenerated hold for a virtual and for a stored generated column?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: v for virtual, s for stored

    attgenerated is v for a virtual column, computed when it is read, and s for a stored one, computed when its row is written.

  18. Question 18 of 20 A generated column must call with_tax(amount), an IMMUTABLE SQL function you created. Which kind does PostgreSQL 18 accept?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: Only STORED

    PostgreSQL 18 reports that virtual generated columns using user-defined functions are not yet supported. Stored generated columns have no such limit, as long as the function is immutable.

  19. Question 19 of 20 What happens to INSERT INTO orders (amount, tax) VALUES (10.00, 1.80) when tax is a generated column?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: It fails, because a generated column takes no value from a statement

    The run printed cannot insert a non-DEFAULT value into column "tax" with Column "tax" is a generated column. The keyword DEFAULT is the only value either kind accepts.

  20. Question 20 of 20 On PostgreSQL 18, queries filter on a virtual column tax, and the planner's row estimates for them are poor. What helps?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: Make tax a STORED column, which ANALYZE then covers like any other

    Virtual columns have no statistics in PostgreSQL 18: ANALYZE skips them and CREATE STATISTICS refuses them. STORED gives the planner real statistics, at the cost of the column's storage and of a rewrite when it is added.

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.

  1. 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 and INSERT ... 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.

  2. 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?

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

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

Related tools

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.