pg-keys-and-types
Skill1 starsChanged 26 days agoMITView on GitHub
Use when creating or altering a PostgreSQL table or choosing a column type or primary key (CREATE TABLE, schema.sql, migrations). Key rules: bigint GENERATED ALWAYS AS IDENTITY, not serial; UUID keys default to uuidv7() on PG 18+ (gen_random_uuid() keys made the index 39% bigger in our test); timestamptz, not timestamp; text with CHECK, not varchar(n); numeric for money; NOT NULL, CHECK and foreign keys, with an index on every FK column.
In this repository22 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- new-skillSkill
- review-skillSkill
- db-auditSkill
- db-connection-budgetSkill
- db-explainSkill
- mysql-connectionsSkill
- mysql-indexingSkill
- mysql-keys-and-typesSkill
- mysql-online-ddlSkill
- mysql-query-patternsSkill
- mysql-throughput-per-connectionSkill
- mysql-transactions-and-lockingSkill
- pg-config-by-sizeSkill
- pg-connectionsSkill
- pg-indexingSkill
- pg-observabilitySkill
- pg-online-migrationsSkill
- pg-query-patternsSkill
- pg-throughput-per-connectionSkill
- pg-transactions-and-lockingSkill
- pg-vacuum-and-bloatSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 17
When the key must be a UUID, default it to uuidv7() (PG 18+.
- Very specific: line 19
Use text with a named CHECK (char_length(col) BETWEEN m AND n) instead of varchar(n) or char(n).
- Very specific: line 22
Use jsonb (never json) only for attributes with no fixed shape, and promote any key you filter, join, sort, or constrain to a typed column.
- Distinctive: line 113
When reviewing schema or SQL under this skill, confirm:
- Distinctive: line 127
Identifiers created outside the database: When clients, other services or offline devices create ids before insert, use a UUID key …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Keys and column types
The decision
Every CREATE TABLE picks a key and a type for each column, and those choices are the hardest to change later: changing a column's type normally rewrites the whole table and its indexes under an ACCESS EXCLUSIVE lock. Get them wrong and the cost shows up months later: an integer key that stops at 2147483647, a random UUID index that is 39% larger than it needs to be, timestamps that are off by the writer's time zone, totals that do not add up, and rows the application cannot use because nothing stopped them. This skill fixes the default for keys, times, strings, money, nullability, constraints and JSON.
The rule
- Make surrogate primary keys
bigint GENERATED ALWAYS AS IDENTITY, neverserialorinteger. - When the key must be a UUID, default it to
uuidv7()(PG 18+; below 18, generate UUIDv7 in the application), nevergen_random_uuid(). - Store points in time as
timestamptz, nevertimestampwithout time zone. - Use
textwith a namedCHECK (char_length(col) BETWEEN m AND n)instead ofvarchar(n)orchar(n). - Store money as
numeric(p, s)(orbigintminor units), neverreal,double precisionormoney. - Declare every column
NOT NULLunless "unknown" is a real state, enforce value rules withCHECK, every reference with aFOREIGN KEY, and index every referencing column. - Use
jsonb(neverjson) only for attributes with no fixed shape, and promote any key you filter, join, sort, or constrain to a typed column.
Why
Rule 1. serial is "not a true type", only shorthand for an integer column with a separate sequence as its default, and its range is "1 to 2147483647"; the docs say to use bigserial "if you anticipate the use of more than 2^31 identifiers over the lifetime of the table" (numeric types). The bad example sets the sequence to that maximum and the next insert fails with SQLSTATE 2200H. Moving to bigint afterwards is a type change, which "will normally cause the entire table and its indexes to be rewritten" (ALTER TABLE). bigint costs 4 more bytes per row and never runs out. GENERATED ALWAYS AS IDENTITY is the SQL-standard form, and it accepts a user-supplied value "only if the INSERT statement specifies OVERRIDING SYSTEM VALUE" (identity columns). The good example gets 428C9 for a hand-written id. With serial, the bad example's import of ids 1 and 2 goes in quietly, and the next normal insert fails with 23505 because the sequence never saw them.
Rule 2. A B-tree fills leaf pages to its fillfactor (90 by default) "when extending the index at the right (adding new largest key values)". Pages that become completely full "will be split, leading to fragmentation" (CREATE INDEX). gen_random_uuid() (and its PG 18 alias uuidv4()) is "random", while uuidv7() is "time-ordered" (UUID functions). The PostgreSQL 18 release describes it as giving "better indexing and read performance for UUIDs" (PostgreSQL 18 released). Measured in this skill's examples with 200000 rows: random UUIDs leave leaf pages about 65% full and an 8.7 MB primary key. The same keys inserted in order give 90% and 6.3 MB, the same as uuidv7() on PG 18. Random keys also spread every insert over the whole index, so the working set is the whole index rather than its right edge. Keep bigint when the id never leaves your system. It is 8 bytes against 16, and the bigint index was 4.5 MB.
Rule 3. For timestamp without time zone, "PostgreSQL will silently ignore any time zone indication". For timestamptz, "the value is stored internally as UTC" and is converted to the session's TimeZone on output (date/time types). In the bad example, 09:00 in São Paulo and 21:00 in Tokyo are the same instant but are stored 12 hours apart. In the good example they are one value, 12:00+00. timestamptz is 8 bytes, the same as timestamp.
Rule 4. "There is no performance difference" among text, varchar(n) and varchar, and char(n) "is usually the slowest of the three because of its additional storage costs" (character types). varchar(n) enforces only a maximum. It still stores '', and an explicit cast "will be truncated to n characters without raising an error", which the bad example shows by cutting an email to 20 characters. A CHECK states the real rule (minimum and maximum) and fails loudly. Changing it is cheap: drop and add the constraint NOT VALID, then VALIDATE CONSTRAINT, which takes only SHARE UPDATE EXCLUSIVE (ALTER TABLE). The good example keeps the same file node. Shrinking a varchar limit in the bad example wrote a new copy of the table.
Rule 5. numeric "is especially recommended for storing monetary amounts and other quantities where exactness is required", while real and double precision "are inexact" (numeric types). Ten orders of 0.10 sum to exactly 1.00 in numeric and to 0.9999999999999999 in double precision. The money type's fractional precision "is determined by the database's lc_monetary setting", and loading a dump into a database with a different lc_monetary "might not work" (monetary types).
Rule 6. The docs advise that "in most database designs the majority of columns should be marked not null" (constraints). A nullable total drops out of both total >= 0 and total < 0, so reports silently miss rows. A CHECK "is satisfied if the check expression evaluates to true or the null value", so a CHECK without NOT NULL lets NULL through (same page). Without REFERENCES, the bad example stores an order for customer 999, who does not exist. Declaring a foreign key "does not automatically create an index on the referencing columns", and without one a DELETE on the parent "will require a scan of the referencing table" (same page).
Rule 7. json "stores an exact copy of the input text, which processing functions must reparse on each execution" and keeps duplicate keys. jsonb keeps only the last duplicate, supports indexing, and "most applications should prefer" it (JSON types). The bad example also shows that json has no = operator (42883). Neither type gives you types, NOT NULL or foreign keys inside the document: the bad example's "total": "abc" is accepted, while the typed numeric column refuses it (22P02). Any update "acquires a row-level lock on the whole row", so keep documents small and atomic (same page).
Good example
See examples/good/test.sql.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (id),
status text NOT NULL CHECK (status IN ('pending', 'paid', 'shipped')),
total numeric(12, 2) NOT NULL CHECK (total >= 0),
placed_at timestamptz NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'
);
-- Declaring the FK does not create this index; deletes on customers need it.
CREATE INDEX orders_customer_id_idx ON orders (customer_id);-- PG 18+: a time-ordered UUID key behaves like bigint identity.
EXECUTE 'CREATE TABLE devices (id uuid PRIMARY KEY DEFAULT uuidv7(), name text NOT NULL)';The test asserts that:
- an explicit id is refused (
428C9), and so are aNULLtotal (23502), a negative total, a misspelled status, an empty or 300-character email (23514), an unknown customer (23503), and a non-numeric total (22P02); - 09:00 in São Paulo and 21:00 in Tokyo are stored as one instant, 12:00 UTC;
- ten totals of 0.10 sum to exactly 1.00;
- raising the email length limit through a
NOT VALIDCHECKkeeps the same table file; jsonbkeeps the last duplicate key and compares documents with=;orders.customer_idhas its own index;- 200000
bigintidentity keys, and on PG 18 200000uuidv7()keys, fill leaf pages to at least 85%. On PG 15 theuuidv7()part is skipped with a notice.
Bad example
See examples/bad/test.sql.
-- MISTAKE: serial is a 4-byte integer with a separate sequence; it runs out at 2147483647
-- and does not notice ids written by hand.
CREATE TABLE customers (
id serial PRIMARY KEY,
-- MISTAKE: varchar(20) is a guess at a business rule; it still accepts '' and casts truncate silently.
email varchar(20),
-- MISTAKE: timestamp without time zone stores wall-clock time and drops the offset.
created_at timestamp DEFAULT now()
);
CREATE TABLE orders (
-- MISTAKE: random UUIDv4 keys land on random leaf pages and split them half full.
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- MISTAKE: no REFERENCES and no NOT NULL; orphans and missing values get in.
customer_id integer,
status varchar(10),
-- MISTAKE: double precision cannot represent 0.10 exactly.
total double precision,
placed_at timestamp,
-- MISTAKE: json keeps duplicate keys, reparses on every read and has no = operator.
details json
);This is the schema written from habit or copied from another database. The assertions show the costs:
- the
serialkey collides with hand-inserted ids (23505) and stops at 2147483647 (2200H); varchar(20)stores'', a cast silently truncates an email, and shrinking the limit rewrites the table;timestampstores the same instant 12 hours apart;double precisionsums ten 0.10 to less than 1;- a
NULLtotal drops out of both halves of a report, and an order for a missing customer is stored; jsonkeeps both copies of a duplicate key, accepts"total": "abc"and cannot be compared;- the same 200000 UUIDs give a primary key under 75% leaf density and over 30% larger when inserted in random order than when inserted in key order.
Checklist
When reviewing schema or SQL under this skill, confirm:
- Every surrogate key is
bigint GENERATED ALWAYS AS IDENTITY(orBY DEFAULTonly where imports must keep ids), with noserial,integerorsmallintkeys. - Every UUID key defaults to
uuidv7()on PG 18+, or is generated as UUIDv7 by the application below 18, nevergen_random_uuid(). - No column has type
timestampwithout time zone, except a wall-clock value that is deliberately zone-free. - No column is
varchar(n)orchar(n); length rules are namedCHECKconstraints ontext. - Every money column is
numeric(p, s)or an integer of minor units, and none isreal,double precisionormoney. - Every nullable column has a written reason why "unknown" is a valid state.
- Every column that holds another table's id has a
FOREIGN KEYand an index that starts with that column. - Every enumerated or ranged value (status, quantity, percentage) has a
CHECK. - No column is
json, and nojsonbkey is used inWHERE,JOIN,ORDER BYor a uniqueness rule without being promoted to a typed column.
When not to apply
- Identifiers created outside the database. When clients, other services or offline devices create ids before insert, use a UUID key generated as UUIDv7 by whoever creates it. Keep
GENERATED BY DEFAULT AS IDENTITYfor tables whose ids an import or a replication tool must preserve. - Ids that must not reveal order or creation time. A UUIDv7 contains its creation time, and
uuid_extract_timestamp()returns it (UUID functions). If a public id must not leak when or in what order rows were created, expose a random token in a separate unique column and keep the ordered key internal. - Wall-clock values that are not instants. A store's opening time, or a recurring "09:00 local" alarm, is a
timeortimestampplus a separate time zone column, because the instant changes with the zone's rules. - Fixed-length codes defined by a standard. An ISO 4217 currency code or country code can be
textwithCHECK (col ~ '^[A-Z]{3}$'). The regular expression states the rule more precisely thanchar(3). - Extremely high-volume measurements. Sensor readings or metrics where small rounding errors are acceptable may use
double precisionfor speed and size. That never applies to amounts that are billed or reconciled. - Truly optional data and staging tables. A column filled in later in a workflow (
shipped_at) is nullable by design. A raw import table can be loose, as long as the copy into the real table applies every constraint. - Payloads stored as received. A webhook body kept for audit can be
jsonb(orjsonif you must keep key order and duplicates byte for byte), as long as no business logic queries inside it.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

