Goal: predictable, readable, and consistent names.
You are a new engineer joining a team that ships fast. You inherit a
database that has grown for years: some tables are CamelCase, others are
snake_case; a few columns are called ts, nm,
and data; there is a table named user that
needs quotes everywhere; and two different status columns
mean different things. Reviews take too long because people argue about
names instead of behavior. Small migrations break because quoted
identifiers and mixed styles cause mistakes. You need a simple, shared
set of naming rules so everyone can guess names without looking them up
and write queries without quoting.
Good names remove friction. Adopt these and apply them everywhere:
user, order
and group; prefer app_user,
purchase_order, user_group.user_id,
is_active, has_image,
can_publish.chk_,
fk_, idx_, uidx_.user_roles.comment_count, total_cents); include
currency when storing money.recalculate_account_balance); trigger functions name
table + action (e.g. trg_posts_set_updated_at); document
SECURITY DEFINER.order_status); enum values lowercase
(e.g. 'pending', 'paid').auth,
billing, analytics,
internal).Start by taking an inventory, agree on rules, then rename safely in small steps.
Goal: predictable, readable, and unquoted names that the team can guess without looking them up.
users, orders.id as GENERATED ALWAYS AS IDENTITY.<entity>_id (e.g.,
user_id, order_id).created_at, updated_at,
optional deleted_at for soft delete.is_, has_,
can_ (e.g., is_active,
has_image).amount_cents, amount_currency).order_status),
lowercase enum values (e.g., 'pending').role_user or conventional
user_roles—pick one convention and keep it.pk_<table> (e.g.,
pk_users)uidx_<table>__<col1>[_<colN>] (e.g.,
uidx_users__email)idx_<table>__<col1>[_<colN>]fk_<from_table>__<to_table>
(e.g., fk_orders__users)chk_<table>__<topic>
(e.g., chk_orders__amount_cents_nonneg)v_,
mv_ if your team prefers.auth,
billing, analytics,
internal).Why: the scheme avoids reserved words, casing pitfalls, and cryptic abbreviations, and makes diffs/scans obvious.
-- Mixed or non-lowercase tables (require quoting)
SELECT
n.nspname AS schema,
c.relname AS tableFROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE
-- c: ordinary table
-- p: partitioned table
-- v: view
-- m: materialized view
c.relkind IN ('r', 'p', 'v', 'm')
AND c.relname <> lower(c.relname)
ORDER BY
1,
2;
-- Mixed or non-lowercase columns (require quoting)
SELECT
table_schema,
table_name,
column_name
FROM
information_schema.columns
WHERE
column_name <> lower(column_name)
ORDER BY
1,
2,
3;
-- Objects named like reserved words (consider renaming)
SELECT
n.nspname,
c.relname
FROM
pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE
c.relkind IN ('r', 'p')
AND c.relname IN ('user', 'order', 'group');Example “bad” table:
-- Problems: reserved word (`User`), mixed case (needs quotes), cryptic names, unclear timestamp type
CREATE TABLE "User" (
"Id" SERIAL PRIMARY KEY,
"Nm" TEXT,
"TS" TIMESTAMP,
email TEXT UNIQUE
);Target (good) outcome:
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
email TEXT NOT NULL,
CONSTRAINT uidx_users__email UNIQUE (email)
);RENAME COLUMN.ALTER TABLE "User" RENAME TO users;ALTER TABLE users RENAME COLUMN "Nm" TO name;
ALTER TABLE users RENAME COLUMN "TS" TO created_at;-- TIMESTAMP -> TIMESTAMPTZ and add default
ALTER TABLE users
ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN created_at SET NOT NULL;
-- Move from SERIAL to IDENTITY (optional modernization)
-- Note: confirms existing sequence ownership first; do this in a quiet window.
ALTER TABLE users ALTER COLUMN id DROP DEFAULT; -- breaks SERIAL link
DROP SEQUENCE IF EXISTS "User_Id_seq"; -- name may vary
ALTER TABLE users ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;-- Primary key
ALTER TABLE users RENAME CONSTRAINT "User_pkey" TO pk_users; -- if present
-- Unique index on email (recreate with clear name if needed)
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public'
AND indexname = '"User_email_key"'
) THEN
ALTER INDEX "User_email_key" RENAME TO uidx_users__email;
END IF;
END $$;
-- Or ensure existence explicitly
CREATE UNIQUE INDEX IF NOT EXISTS uidx_users__email ON users(email);-- Read‑only compatibility view for old code paths
CREATE OR REPLACE VIEW "User" AS
SELECT id, name, created_at, email FROM users;
-- If writes to the old name are still happening, add simple rules (optional)
-- INSERT example:
CREATE OR REPLACE RULE user_insert_redirect AS
ON INSERT TO "User" DO INSTEAD
INSERT INTO users(id, name, created_at, email)
VALUES (NEW.id, NEW.name, NEW.created_at, NEW.email);-- Renames keep privileges, but new objects (views/rules) may need grants
GRANT SELECT ON users TO readonly;
COMMENT ON TABLE users IS 'Application users (human accounts)';
COMMENT ON COLUMN users.created_at IS 'Creation timestamp (UTC)';-- After application is deployed and no longer uses the old name
DROP VIEW IF EXISTS "User" CASCADE; -- removes rules attached to the viewWhen renaming a hot column without locking DDL:
ALTER TABLE users ADD COLUMN created_at timestamptz;).user, order,
group create friction; avoid them.