Sql generated and identity columns
Skill ctoth/sql-skills-plugin/plugins/sql/skills/sql-generated-and-identity-columns
32 agent skills for writing correct, portable, modern standard SQL (ISO/IEC 9075) — vendor-neutral, with per-feature portability across PostgreSQL, MySQL/MariaDB, SQLite, and more.
npx -y skills add ctoth/sql-skills-plugin --skill sql-generated-and-identity-columnsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Guides the two standard SQL ways to let the database derive a column value for you — `GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY` for surrogate keys instead of vendor `SERIAL`/`AUTO_INCREMENT`/`IDENTITY(1,1)`, and `GENERATED ALWAYS AS (expr) STORED|VIRTUAL` computed columns instead of recomputing a derived value in every query or maintaining it by hand in application code. Teaches the load-bearing distinctions — `ALWAYS` (rejects explicit inserts unless `OVERRIDING SYSTEM VALUE`) vs `BY DEFAULT` (serial-like, lets a supplied value win); `STORED` (disk, computed on write) vs `VIRTUAL` (compute on read); that a generated column can be read but never written directly; and that a generation expression must be deterministic, same-row, and cannot reference another generated column. Auto-invokes when writing or editing auto-increment / surrogate-key columns, `SERIAL`/`AUTO_INCREMENT`/`IDENTITY(1,1)`, computed/derived columns, `GENERATED` clauses in `CREATE TABLE`, or on "auto-incrementing id" / "computed column" / "store a total/full-name/age" requests. Builds on the foundation `sql-relational-and-null-discipline`.
SKILL.md
15.4 KB, ~3.3k tokens by cl100k_base, as published. Nobody here has run it
SQL Generated and Identity Columns
"The data types
smallserial,serialandbigserialare not true types, but merely a notational convenience for creating unique identifier columns ... Another way is to use the SQL-standard identity column feature." — PostgreSQL — Serial Types
"A generated column is a special column that is always computed from other columns. Thus, it is for columns what a view is for tables." — PostgreSQL — Generated Columns
Two features share the GENERATED keyword and one core idea: let the engine derive the value so nothing downstream has to. Identity columns derive a surrogate key from a sequence; generated columns derive a value from other columns in the same row. Both replace error-prone vendor shortcuts or hand-maintained app logic with a declaration the database enforces on every write. This skill builds on the foundation sql-relational-and-null-discipline (set semantics and three-valued logic — a VIRTUAL column's expression is evaluated under exactly those NULL rules) and routes vendor spellings to sql-standard-vs-dialect-map.
1. Surrogate Keys: GENERATED AS IDENTITY, Not SERIAL/AUTO_INCREMENT
The portable, standard way to get an auto-incrementing key is the identity column. PostgreSQL is blunt that the vendor alternative is a convenience, not a type: serial/bigserial "are not true types, but merely a notational convenience for creating unique identifier columns (similar to the AUTO_INCREMENT property supported by some other databases)," and the docs point you at the standard: "Another way is to use the SQL-standard identity column feature" (PostgreSQL — Serial Types). SERIAL is just sugar for a hidden CREATE SEQUENCE plus a DEFAULT nextval(...); it carries vendor-specific baggage and doesn't port.
-- WRONG — SERIAL is PostgreSQL-specific sugar; AUTO_INCREMENT/IDENTITY(1,1) are other dialects
CREATE TABLE people (
id SERIAL PRIMARY KEY, -- non-standard; hidden sequence + DEFAULT nextval()
name text NOT NULL
);
-- RIGHT — the SQL-standard identity column, portable in intent
CREATE TABLE people (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
Use GENERATED ... AS IDENTITY (PostgreSQL — Identity Columns). PK/NOT NULL/UNIQUE rules on the key belong to sql-constraints-and-integrity; integer width (int vs bigint) belongs to sql-data-types-and-numerics; mapping SERIAL/AUTO_INCREMENT/IDENTITY(1,1) across engines belongs to sql-standard-vs-dialect-map.
2. ALWAYS vs BY DEFAULT — Who Wins When the App Supplies an id
This is the most consequential choice, and it is about what happens when an INSERT supplies an explicit value for the identity column:
"if
ALWAYSis selected, a user-specified value is only accepted if theINSERTstatement specifiesOVERRIDING SYSTEM VALUE. IfBY DEFAULTis selected, then the user-specified value takes precedence." — PostgreSQL — Identity Columns
GENERATED ALWAYS AS IDENTITY(recommended default): the engine owns the value. An accidental app-suppliedidis rejected with an error, not silently accepted. To force one — bulk loads, data migration, replication — the writer must opt in explicitly withOVERRIDING SYSTEM VALUE.GENERATED BY DEFAULT AS IDENTITY: serial-like. A supplied value wins; the sequence is used only when no value is given. Convenient for restoring dumps, but it lets app code set keys — and a supplied value does not advance the sequence, so later auto-generated values can collide.
-- With GENERATED ALWAYS: this INSERT ERRORS (value not accepted)...
INSERT INTO people (id, name) VALUES (42, 'Ada');
-- ...unless you explicitly override:
INSERT INTO people (id, name) OVERRIDING SYSTEM VALUE VALUES (42, 'Ada');
-- Either way, the normal path lets the engine assign the key:
INSERT INTO people (name) VALUES ('Ada'); -- omit the column
INSERT INTO people (id, name) VALUES (DEFAULT, 'Ada'); -- or request the sequence value
Prefer ALWAYS so the surrogate key cannot be poisoned by hand; reach for BY DEFAULT only when a tool genuinely needs to insert explicit key values.
3. Computed Columns: GENERATED ALWAYS AS (expr)
A computed (generated) column derives its value from other columns in the same row — "a special column that is always computed from other columns. Thus, it is for columns what a view is for tables" (PostgreSQL — Generated Columns). The value is maintained by the engine on every write; you declare the rule once.
-- RIGHT — derive once, in the schema; every reader sees a consistent value
CREATE TABLE people (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
height_cm numeric,
height_in numeric GENERATED ALWAYS AS (height_cm / 2.54) STORED
);
Generated columns are a standardized optional feature — "ISO/IEC 9075-2:2023 ... optional feature T175" (modern-sql.com — Generated Columns). The expression must be deterministic and reference only constant literals and columns within the same row — it "may not use subqueries, aggregate functions, window functions, or table-valued functions" (SQLite — Generated Columns). So a column can hold price * qty or lower(email), but not "the sum of this customer's orders" — that derivation lives in a view or query.
4. STORED vs VIRTUAL — Disk Now or CPU Later
The two kinds differ only in when the value is computed, because a deterministic expression yields the same answer whenever you evaluate it:
"A stored generated column is computed when it is written (inserted or updated) and occupies storage as if it were a normal column. A virtual generated column occupies no storage and is computed when it is read." — PostgreSQL — Generated Columns
SQLite states the tradeoff crisply: "STORED columns take up space in the database file, whereas VIRTUAL columns use more CPU cycles when being read" (SQLite — Generated Columns). The mental model: a VIRTUAL column "is similar to a view" and a STORED column "is similar to a materialized view (except that it is always updated automatically)" (PostgreSQL — Generated Columns).
-- STORED — pay disk; good when read often, expensive to compute, or you need to INDEX it
total numeric GENERATED ALWAYS AS (price * qty) STORED
-- VIRTUAL — pay CPU on read; good when cheap to compute and rarely read, or to save space
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL
Choose STORED when the value is read far more than written, is costly to compute, or must be indexed; choose VIRTUAL when it's cheap and you want to save space. Indexing is often the deciding factor — a STORED column "does require storage space and can be indexed," whereas an index on a derived value generally needs the value materialized (MySQL — Generated Columns). (Concatenation NULL behavior here follows three-valued logic — first_name || NULL is NULL; see the foundation.)
5. You Cannot Write a Generated Column Directly
A generated column is output-only. An INSERT/UPDATE that tries to supply a value for it is an error, not a silent no-op:
"A generated column cannot be written to directly. In
INSERTorUPDATEcommands, a value cannot be specified for a generated column, but the keywordDEFAULTmay be specified." — PostgreSQL — Generated Columns
SQLite agrees: "Generated columns can be read, but their values can not be directly written. The only way to change the value of a generated column is to modify the values of the other columns used to calculate" it (SQLite — Generated Columns).
-- WRONG — supplying total fails; total is derived, not stored input
INSERT INTO line_items (price, qty, total) VALUES (10, 3, 30);
-- RIGHT — write only the inputs; the engine computes total
INSERT INTO line_items (price, qty) VALUES (10, 3);
-- (or pass the keyword DEFAULT in the generated column's position)
Likewise, "a generation expression cannot reference another generated column" (PostgreSQL — Generated Columns) — chain through the base columns, not through a second generated column.
6. Push Derivation Into the Schema, Not Into Every Write Path
The reason to use a generated column instead of an app-maintained field is a single source of truth the engine keeps consistent. A generated column is "always computed" and a stored one is "always updated automatically" (PostgreSQL — Generated Columns); because the expression is deterministic, "in principle, it doesn't matter when it is evaluated" (modern-sql.com — Generated Columns). The schema, not the next caller, owns the formula.
-- WRONG — a plain column the application must remember to recompute on every write.
-- Miss one code path (a bulk update, a second service, a manual fix) and it drifts.
ALTER TABLE orders ADD COLUMN total numeric; -- app sets total = price * qty ... sometimes
-- RIGHT — the engine recomputes on every write; impossible to forget, impossible to desync
ALTER TABLE orders ADD COLUMN total numeric GENERATED ALWAYS AS (price * qty) STORED;
The same logic favors an identity column over app-generated keys: one writer can't accidentally hand out a duplicate or stale id when the database owns the sequence.
7. Portability Snapshot
The standard surfaces are GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY and GENERATED ALWAYS AS (expr) [STORED|VIRTUAL] (feature T175, modern-sql.com). Support and defaults diverge:
| Capability | PostgreSQL | SQLite | MySQL |
|---|---|---|---|
GENERATED AS IDENTITY (standard) | yes | no (use INTEGER PRIMARY KEY rowid) | no (use AUTO_INCREMENT) |
Computed GENERATED ALWAYS AS (expr) | yes | yes | yes |
STORED computed columns | yes | yes | yes |
VIRTUAL computed columns | no (STORED only) | yes (default) | yes |
| Default when keyword omitted | n/a (STORED required) | VIRTUAL | VIRTUAL |
PostgreSQL supports only STORED generated columns. SQLite and MySQL support both, and in both, VIRTUAL is the default when neither keyword is written — "If the trailing VIRTUAL or STORED keyword is omitted, then VIRTUAL is the default" (SQLite); "The default is VIRTUAL if neither keyword is specified" (MySQL — Generated Columns). Always write STORED/VIRTUAL explicitly so the same DDL means the same thing everywhere. For surrogate keys, only PostgreSQL has the standard identity syntax; SERIAL, AUTO_INCREMENT, IDENTITY(1,1), and the SQLite rowid all route to sql-standard-vs-dialect-map.
8. Who Suffers When Derivation Is Done by Hand
These features fail loudly at write time or never — the pain is in the version without them:
- The analyst reconciling a report where the order
totalcolumn disagrees withprice * qtyfor a few thousand rows. A second service wrote orders without running the app's "recompute total" code, and the derived field drifted (§6). AGENERATED ALWAYS AS (...) STOREDcolumn could not have desynced — the engine maintains it on every write. - The backend engineer whose
INSERTsuddenly errors in production after someone changed a column toGENERATED ALWAYS AS IDENTITY: their ORM was supplying an explicitid, whichALWAYSrejects unless the statement saysOVERRIDING SYSTEM VALUE(§2). The fix is to stop sending the key, not to weaken the column. - The DBA migrating a
SERIALPostgreSQL schema to another engine and discoveringSERIALwas never a real type — just a hidden sequence — so the keys, ownership, andnextvaldefaults don't carry over (§1). Had the schema used standard identity columns, the surprise would have been smaller and documented. - The next maintainer who writes
INSERT INTO line_items (price, qty, total) ...against a table wheretotalis generated, and has to learn the hard way that a generated column "cannot be written to directly" (§5).
A derived value owned by the schema is a promise the database keeps for everyone who writes to the table — including the writers nobody remembered.
9. Routing to Related Skills
sql-relational-and-null-discipline— foundation: set semantics and three-valued logic; aVIRTUAL/computed expression evaluates NULLs under exactly those rules.sql-constraints-and-integrity—PRIMARY KEY/UNIQUE/NOT NULLon identity and generated columns, and the rule that generated columns can't be a SQLite PRIMARY KEY.sql-data-types-and-numerics— choosing the integer width for an identity key (intvsbigint) and the numeric type of a computed column.sql-standard-vs-dialect-map— dialect spellings ofSERIAL/AUTO_INCREMENT/IDENTITY(1,1), the SQLite rowid, and per-engineSTORED/VIRTUALdefaults.
10. Reference Files
High-frequency identity/generated-column anti-patterns in LLM-generated SQL, each with wrong/right code and a primary-source citation:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml
What ships with it: 2 files
12.8 KB alongside SKILL.md
references/
- common-mistakes.md9.2 KB
- sources.yaml3.6 KB