PG.L8.NAMING_SNAKE_CASE — An identifier PostgreSQL will not hand back the way you wrote it
- Category: convention
- Level: 8
- Confidence: deterministic
- Downtime class: none
- Stability: stable
- Suites: lint, audit
- Applies to: PostgreSQL 18 and newer
This is a case rule, not a taste rule
Level 8 is where conventions live, and most of what belongs there is preference. This one is not.
PostgreSQL folds an unquoted identifier to lower case. So these two statements do not create the same table:
CREATE TABLE "Orders" (id bigint); -- a table named Orders
CREATE TABLE Orders (id bigint); -- a table named orders
Afterwards SELECT * FROM Orders reaches the second and never the first, because the reference is
folded too. A schema that ended up with both has two tables where every reader believes there is
one — and the one they cannot reach is the one holding the rows.
The same applies to columns, indexes, constraints, views and sequences. A column created as
"createdAt" is only ever reachable as "createdAt", quotes included, in every query and every
migration for the rest of its life.
Flagged
Schema::create('Orders', function (Blueprint $table) {
$table->id();
$table->timestamp('createdAt');
});
Preferred
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->timestamp('created_at');
});
What it reports and what it does not
It judges the name of a table, column, index, constraint, view, materialized view or sequence. It does not judge roles, grants, settings or extensions: those are not names the project chose.
It also does not filter catalog noise itself. Extension-owned objects, partitions and the Laravel table prefix are handled once, centrally, by the catalog reader — a second copy of that logic here would drift from the first, and the drift would show up as findings on objects nobody owns.
Both before and after
The rule runs in lint against the migration that introduces the name, and in audit against the
catalog. That is deliberate: lint catches it while it is still a diff and costs nothing to change,
audit catches the one that arrived some other way — a hotfix, an older migration, a table made by
hand.
A migration that introduces several such names reports them together in one finding rather than one at a time. A finding is located at a statement, so a second finding for the same statement would be deduplicated away by rule id and location, and the extra names would vanish silently.
Sources
- PostgreSQL 18: lexical structure — unquoted identifiers are folded to lower case; a quoted one keeps the case it was written in.
- PostgreSQL 18:
CREATE TABLE— the name given here is the name every later reference has to resolve to.