We were rewriting the table anyway — an ALTER COLUMN TYPE that forced a full rewrite — when someone asked whether we should reorder the columns while we were paying for it. The table was 410 million rows, 74 GB, twenty-two columns that had accreted over six years in whatever order the feature requests arrived: a bool here, a timestamptz there, another bool, an int8, three more bools. We rebuilt it with the columns sorted by alignment — eight-byte types first, then four, two, one — and the new heap came back at 66 GB. Eleven percent smaller, identical data, identical schema semantics, zero application change. Seven gigabytes of that table had been alignment padding: empty bytes PostgreSQL inserts so that every value starts at an address its type requires, multiplied by 410 million rows.
This is the cheapest performance-adjacent optimization in PostgreSQL — it costs nothing at table-creation time and is nearly free to piggyback on a rewrite you are doing anyway — and almost nobody checks it. Here is the math, the measurement, and the honest list of when not to bother.
Does column order really change how big a table is?
Yes — PostgreSQL stores each column's value starting at an offset aligned to that type's requirement, and it inserts unnamed padding bytes to get there, so the same columns in different orders produce different row sizes. The alignment requirements come from the type's typalign in pg_type: one byte for bool and "char", two for int2, four for int4, float4, and the varlena types like text, and eight for int8, float8, timestamp, and timestamptz on typical 64-bit builds. The arithmetic that made our 7 GB: a row laid out as bool, int8, bool, int8 spends one byte on the first bool, seven bytes of padding to reach the next eight-byte boundary, eight on the int8, one on the bool, seven more of padding, eight on the second int8 — thirty-two bytes for eighteen bytes of data. The same four columns ordered int8, int8, bool, bool need eighteen bytes with no padding at all. Fourteen bytes per row, times hundreds of millions of rows, times every replica and every backup, is a number worth a fifteen-minute review at CREATE TABLE time.
How does PostgreSQL lay out a row on disk?
Every heap tuple starts with a 23-byte header — the xmin and xmax that drive visibility, the ctid self-pointer, and flag bytes, padded up to 24 bytes on eight-byte-aligned builds — followed by a null bitmap if the row contains any nulls, and then the column values in attnum order, each aligned as described above. The visibility machinery behind that header is a topic of its own and is covered in the MVCC tuple visibility notes; what matters here is what follows it. Two consequences of the layout are worth knowing before you over-optimize. First, nulls do not occupy column space — a null int8 costs its bit in the null bitmap, not eight bytes — so "put the nullable columns last" is a myth as far as storage goes; order by alignment, not nullability. Second, dropped columns leave a gap in attnum but not in storage: rows written after the drop simply do not store that column, so a table with a dropped column in the middle behaves as if the remaining columns were declared in their surviving order — no special handling needed, but it explains why pg_attribute shows attisdropped rows with gaps in the numbering.
How do you measure the waste before touching anything?
You compare the current row size against the reordered row size on a sample, because the per-row delta times the row count is the entire business case. The quick estimator is pg_column_size applied to a row value, which reports the storage size of a composite including its alignment padding:
SELECT
pg_column_size(ROW(true, 8::bigint, true, 8::bigint)) AS interleaved,
pg_column_size(ROW(8::bigint, 8::bigint, true, true)) AS aligned;
For a real table, the honest measurement is a copy: CREATE TABLE sample AS SELECT * FROM events LIMIT 1000000 with the columns in their current order, a second copy with them sorted by alignment, and pg_relation_size on both — the percentage delta on a million real rows predicts the full-table result closely enough to make the decision, and it costs nothing but temp space. If you already suspect the table has broader space problems, the table bloat measurement guide belongs in the same session, because alignment waste and dead-tuple bloat are different conditions with different cures, and it is common to find both.
What about wide text columns and TOAST?
Variable-length columns participate in alignment at four bytes, so they slot into the ordering between the eight-byte and two-byte groups, and their inline length is irrelevant to the padding question — what matters is that when a value outgrows the roughly 2 KB inline threshold, PostgreSQL compresses it and may move it out of line into the table's TOAST relation, leaving an 18-byte pointer in the main heap. That means the alignment exercise applies to the pointers and fixed columns in the main table, while the bulk of a wide payload lives elsewhere and is unaffected by column order — the mechanics are covered in the TOAST large-values notes. The practical takeaway: on a table whose size is dominated by TOASTed payloads, column tetris saves a few percent of the main heap at most, and your storage problem is a compression or TOAST problem instead. On a table of many narrow columns — flags, foreign keys, timestamps, statuses — the padding fraction is at its largest, and that is where the 11 percent numbers live.
How do you actually reorder columns in production?
You cannot — there is no ALTER TABLE REORDER — so reordering is always a rewrite, and the entire strategy is to never pay for a rewrite solely for this. The playbook, in order of preference. New tables: sort columns at CREATE TABLE time, eight-byte types first, then four, two, one — this costs literally nothing and is the whole game. Existing tables already facing a rewrite — a type change, an ADD COLUMN with a volatile default avoided, a pg_repack for bloat — fold the new column order into that operation, since pg_repack rebuilds the table from a definition you control and can take the reordered column list. Tables with no rewrite on the horizon: leave them alone unless the measured savings clear a high bar — the cutover cost of a CTAS-plus-swap on a hot table dwarfs single-digit gigabytes, and the space is reclaimed only after the rebuild anyway. We treat the pg_column_size audit as a once-a-year schema hygiene check, and the wins it finds go into the backlog to ride along with the next rewrite each table naturally needs.
Watching table growth with MonPG
Alignment waste announces itself only as a table that is bigger than its data justifies, so the signals are the ordinary growth series read with this lens: per-relation size trends, bytes per live row drifting upward as nullable flag columns accumulate, and the before-and-after of any rewrite showing exactly what the reorder bought. MonPG graphs these PostgreSQL series — relation sizes, table statistics, vacuum activity — as part of its PostgreSQL monitoring, which is how our 74-to-66 GB rewrite showed up as a visible step instead of an anecdote. Sort the columns when the table is born, ride the rewrites when they come, and spend the recovered gigabytes on something users can feel.