Life Altering Postgresql Patterns
Life Altering Postgresql Patterns
There is a set of things that you can do when working with a Postgres database
which I have found made my and my coworker's lives much more pleasant. Each
one is by itself small, but in aggregate have a noticeable effect.
Truly random UUIDs doesn't sort well (and this has implications for indexes)
They take up more space than sequential ids (space being your cheapest
resource)
So just always slap a created_at and updated_at on your tables. You can
maintain updated_at automatically with a trigger.
[Link] 1/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
You need to create the trigger for each table, but you only need to create the
function once.
This makes it so that if you try and delete the referenced row you will get an error.
Storage is cheap, recovering data is a nightmare. Better to error than do
something like cascade.
[Link] 2/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
Use schemas
By default, every table in Postgres will go into the "public" schema. This is fine,
but you are missing out if you don't take advantage of your ability to make new
schemas.
Schemas work as namespaces for tables and for any moderate to large app you
are going to have a lot of tables. You can do joins and have relationships between
tables in different schemas so there isn't much of a downside.
[Link] 3/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
Enum Tables
There are a lot of ways to make "enums" in sql. One is to use the actual "enum
types," another is to use a check constraint.
Have a table with some text value as a primary key and make columns in other
tables reference it with a foreign key.
This way you can insert into a table to add more allowed values or attach
metadata like a comment to explain what each value means.
[Link] 4/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
SELECT * FROM pets might seem nicer than SELECT * FROM pet but the moment
you start doing anything more interesting with your queries you will notice that
your queries are actually working in terms of individual rows.
SELECT *
FROM pet
-- It's a cruel coincidence that in english an "s"
-- suffix can sometimes work both as a plural
-- and a possessive, but notice how the where clause
-- is asserting a condition about a single row.
WHERE [Link] = 'sally'
The deeper you dig the more annoying edge cases you'll run into with plural table
names. Just name your tables the same as what an individual row in that table
represents.
[Link] 5/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
If you have some domain specific need to delete (or otherwise mark as irrelevant)
some data, use a nullable timestamptz column. If there is a timestamp filled in,
that's when it was deleted. If there is no timestamp it isn't deleted yet.
[Link] 6/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
on delete restrict,
issued_at timestamptz not null,
-- Instead of deleting a prescription,
-- explicitly mark when it was revoked
revoked_at timestamptz
);
Even outside the context of a soft delete, timestamps are usually more useful than
a boolean. If you want to know whether something happened, you generally also
want to know when it happened.
Webhooks are a prime example of the 2nd situation. There's no way in the laws of
physics to be sure you'll get events in exactly the right order.
To handle this you should have a table where each row represents the status of
the thing at a given point in time. Instead of overloading created_at or
updated_at for this, have an explicit valid_at which says when that information
is valid for.
Just having an index on valid_at can work for a while, but eventually your
queries will get too slow. There are a lot of ways to handle this, but the one we've
found that works the best is to have an explicit latest column with a cheeky
unique index and trigger to make sure that only the row with the newest
valid_at is the latest one.
[Link] 8/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
UPDATE vet.adoption_approval
SET latest = true
WHERE id = (
SELECT id
FROM vet.adoption_approval
WHERE person_id = NEW.person_id
ORDER BY valid_at DESC
LIMIT 1
);
RETURN null;
END;
$function$;
All rows in an enum table are like this, but you will also end up with rows in
tables of otherwise normal "generated during the course of normal use" rows. For
these, give them a special system_id.
Unique indexes don't mind multiple rows with null values, so you can make a
unique index on this system_id and look up your special rows later as you need
to.
);
They are terrible in that removing obsolete columns requires a drop and
recreation, which can become a nightmare when you build views on views. The
query planner also seems to have trouble seeing through them in general.
So do use views, but only as many as you need and be very wary of building views
on views.
[Link] 10/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
[Link],
[Link].created_at,
[Link].updated_at,
[Link].pet_id,
[Link].issued_at
FROM
[Link]
WHERE
[Link].revoked_at IS NULL;
JSON Queries
You might have heard that Postgres "supports JSON." This is true, but I had
mostly heard it in the context of storing and querying JSON. If you want a table
with some blob of info slap a jsonb column on one your tables.
That is neat, but I've gotten way more mileage out of using JSON as the result of a
query. This has definite downsides like losing type information, needing to realize
your results all at once, and the overhead of writing into json.
But the giant upside is that you can get all the information you want from the
database in one trip, no cartesian product nightmares or N+1 problems in sight.
SELECT jsonb_build_object(
'id', [Link],
'name', [Link],
'pets', array(
SELECT jsonb_build_object(
'id', [Link],
'name', [Link],
'prescriptions', array(
SELECT jsonb_build_object(
'issued_at', [Link].issued_at
)
FROM [Link]
WHERE [Link].pet_id = [Link]
)
)
FROM vet.person_pet
LEFT JOIN [Link]
ON [Link] = vet.person_pet.pet_id
WHERE vet.person_pet.person_id = [Link]
),
[Link] 11/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
'contact_infos', array(
SELECT jsonb_build_object(
'mailing_address', vet.contact_info.mailing_address
)
FROM vet.contact_info
WHERE vet.contact_info.person_id = [Link]
)
)
FROM [Link]
WHERE id = '29168a93-cd14-478f-8c70-a2b7a782c714';
{
"id": "29168a93-cd14-478f-8c70-a2b7a782c714",
"name": "Jeff Computers",
"pets": [
{
"id": "3e5557c0-c628-44ef-b4d1-86012c5f48bf",
"name": "Rhodie",
"prescriptions": [
{
"issued_at": "2025-03-11T23:46:18.345146+00:00"
}
]
},
{
"id": "ed63ca7d-3368-4353-9747-6b6b2fa6657a",
"name": "Jenny",
"prescriptions": []
}
],
"contact_infos": [
{
"mailing_address": "123 Sesame St."
}
]
}
You can find all the setup you'd need to do for that query here. You can try it out
on [Link] if setting up a local postgres is a bit
much.
[Link] 12/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
If there is something I missed or got wrong, tell me very loudly in person or here
on the internet.
<- Index
Hi Ethan. I enjoyed this post. Not a click-baity title at all! One thing I did not quite
understand was the section about "Mark special rows with a system_id". I don't get
what a "special row" is, and why you call the extra column "system_id". Would you be
kind enough to elaborate on this? Aside from that, I found the post very helpful,
thanks!
@ChrisGNZ Sure, so an example might be something like: Say you have a program
with an authorization system where individual users are on teams and they have roles
on those teams and those roles imply permissions.
Different teams can make their own "roles" to assign to their users, but you want to
give the same set of roles to every team out of the box. Those roles might get a
system id so code you run on team creation can find the special roles to assign.
Or if you have a money moving system: An account that a particular feature of your
system uses to store fees is still an account, but has a special role in the "system" -
hence system_id
👍 1
I couldn't understand why you proposed the system_id column, could you clarify?
because if it's a column to trigger a behavior, it could be called exactly the behavior
it's driving right? (i.e.: needs_cdc_review) or similar?
@dvaldivia Its more a column on which to build behavior. Concrete example if the
prose above is a bit obtuse
account
[Link] 13/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
id | display_name | system_id
| user_account_a | null
| user_account_b | null
| fee collection | fee_collection
Say for a particular feature of your app you want to move money between two user
accounts, but a part of what would be moved you want to put into a fee_collection
account you own. Instead of hardcoding the id of that account into your code, you
can refer to it by its system id.
👍 1
In our app, we have 3rd party servicing teams who each have a team / accounts in
our product (and modeled in our DB). At some point we decided to take some of
that work in-house, so we added an in-house team, utilizing the same interfaces /
tools we'd written for the external servicing teams. Over time we started to build
additional functionality that only made sense for the in-house team, so we needed
to know which team (row) was the in-house one.
Could it be flagged with a boolean? Sure, but it's really an identity that we want to
model. And you might find down the road that there is another such team that
needs a system identity handle for a similar purpose.
Closing the loop a bit... in my company's app, all servicing of a certain type goes to
our internal team If that team row didn't exist the app would break Feels like the
👍1
UUID? No! Singular table naming? Yes, but just stick to what your framework wants
you to do. Soft delete everything? God no. Sometimes, yes! Views? What, are we in
2001? updated_at, created_at. Sure, where it makes sense. System_id? Huh? See point
1. Many to many naming? Spot on. Json field? Uff! Enum/type tables? Yeah..they often
grow to include stuff like icon etc..
👍 2
[Link] 14/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
👍 3
👍 2
@ChrisGNZ Thanks for sharing. I'd like to contribute based on my own experience.
2. If you have a multi-column index on, let's say, columns (c1, c2, c3), there's no
need to create a single-column index on c1 - even if the column c1 is a foreign
key or is used explicitly in WHERE clauses. The planner/optimizer can identify the
c1 column as the first one in the multi-column index, allowing its selection and
use. Also, by not creating the single-column index, you save some disk space and
save some time on executing INSERTs, UPDATEs, and DELETEs.
3. Name your DB objects in lowercase (e.g. "person", "pet"). If you use Object-
relational mapping (ORM) tools, it's usual to see DB objects named after classes
defined in code (e.g. "Person", "Pet"), which in turn demands the use of double
quotation marks surrounding the object names when writing queries by your
[Link] 15/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
own. So, if possible, configure your ORM to avoid this annoyance.
👍 1
Very nice! Here are a few twists for people working with Oracle rather than Postgres.
Note the WITH UPDATE INSERT DELETE part: these things support changing the
underlying relational tables by writing to the JSON object, not just querying them. If
the bulk of your web server is assembling SQL results into JSON then this can
actually empty out your web server, at which point you could consider using ORDS
which is a generic REST API server that can drive the database and map to/from
HTTP directly
You can add comments to an enum type, to a column, or to any other database object
using COMMENT: [Link]
This is great, I'll bookmark this for future reference. Thank you very much!
This is a great collection of advice, and I only have one thing to suggest: sometimes
it's better to use a state column rather than a revoked_at column, i.e state is
"created", "updated", "deleted", and sometimes things like "restored", or "virtual"
The rationale is that the logic is 100% easier, "state = deleted" rather than "revoked_at
exists and is before NOW()", and future states are easier to add without creating a
nasty mess of flags.
Similarly, to "log all status", you can have a table that logs any changes to the state ,
which lets you keep timestamps per event, rather than the last event.
👍 1
I always give foreign keys the same name as the related primary key so I can use the
using statement instead of writing on expressions.
Example, getting prescriptions for pets. If you havee pet_id on both sides you write:
rather than:
… ON [Link] = prescription.pet_id
As the polar-opposite of this post, the official Postgres wiki has a fantastic Don't do
this page, full of things that are supported, albeit inadvisable.
Also, Postgres has upcoming support for UUIDv7 (also available today via an
extension), which solves the issue with indexes, and also allows you to extract a
created_at value "for free" (with some caveats)
UUIDs have some nice properties, but they look awful in URLs! ULIDs are a bit better,
but if you're building a web-based system then I really recommend using something
[Link] 17/23
25.02.2026, 10:56 y g y y
Life Altering Postgresql Patterns g g
that follows the general timestamp+randomness theme of UUID v7 and similar, but
has a compact & fairly nice looking URL-safe represenation
I prefer shortid more than UUID. At least it's more human readable.
Possibly a typo in "Name your tables singularly" example? The query uses FROM pet,
but the WHERE clause references [Link].
👍 1
Curious behind the rationale behind giving your person_pet join table it's own id
primary key column instead of just specifying the columns person_id and pet_id
together as the primary key.
I've always found giving join tables their own independent primary key column to end
up being painful in the long run (plus requiring the extra unique index).
Nice list, I would mostly only differ with the UUID advice. Use a ULID for sortable IDs
[Link]
@evmcl I often end up eventually wanting to treat the join table as its own entity -
and as such relate it to other things in the system. The mechanical naming is
something I do when I can't really describe it up front, but it doesn't mean that it will
remain a "pure" join table as time goes on.
Example:
This is a great collection of advice, and I only have one thing to suggest:
sometimes it's better to use a state column rather than a revoked_at column,
i.e state is "created", "updated", "deleted", and sometimes things like "restored",
or "virtual"
Overall, some really solid advice. I have two disagreements, the first is a minor
quibble that I would accept as house style if I went to a new org, the second of
which I think is a major problem that if we worked in the same org I would probably
regularly disagree with every time I encountered it, and see to refactor out for
future maintenance relief.
I.e. if you are joining person and pet, call it person_pet. I know it is SQL-y to call
them 'join' tables, but I prefer to think of them as cross-reference tables, and so I
would call that person_pet_xref. Same concept, I find it more clear to have the Xref
in the table name, especially as it clarifies when reading SQL that this is a xref. Also,
with the _ naming convention in postgres, I find it definitely conceivable that a
system could grow to have a concept that looks like the table name of a join table
when in fact it is something different. Your identity.organization_user might not
in fact be a join table but some separate concept for who uses an organization, not
a user in an organization, that now you'll have to find a new name, whereas if you
had called it identity.user_organization_xref you can use the more natural name.
(I realize I kind of cheated there by calling it user_organization_xref but I think of
users belonging to organization not organizations belonging to users and that's
how I try to structure the Xref table names. I don't think this naming precludes it
👍 2
[Link] 19/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
Very cool article. Most of these rules were baked in as non-negotiable in our Tilda
framework [Link]
A few questions:
uuid v7 is sortable and solves most issues with uuid in terms of cache locality.
we actually found that with fine-tuning Postgres, we could get some really good
performance out of fairly complex views, including views of views with heavy
pivot patterns (200M+ rows, pivoting 400+ columns)
we subsumed the "status as logs" pattern with an automated history pattern on
insert/update. Same results, but better automated imo.
Cheers :)
👍 2
I'm guessing nobody cares about rows where system id id null, but PostgreSQL will
index them unless you explicitly exclude them.
👍 2
You don't need on update restrict and on delete restrict to maintain referential
integrity. The default no action does that just fine. The only difference is that no
action is deferable. And sometimes you want on delete cascade anyway: think
invoices and invoice_line_items. If you delete the invoice you want the associated line
items to be deleted along with it. That's just good semantics, and saves yourself the
extra work of having to first delete the associated line items and only then being able
to delete the invoice. Don't work harder!
👍 2
I don't agree with a lot of what you write, so they're hardly "life altering". Or maybe
they are, but for me that would be in the wrong way.
UUIDs are good if you have distributed systems, but for the rest of us bigints are
[Link] 20/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
just fine and take half the space. And BTW many tables in many systems will never
get anywhere close to 2 billion rows; in that case why not just use ints for those
tables?
Not all tables need created_at and updated_at. Some tables contain almost static
reference data, for example.
You have extremely wordy SQL in your create table scripts and triggers:
My biggest tip would be to stop using the shift key when typing SQL. Save yourself
👍 1
@cthart +1 for losing the uppercase convention in SQL. Even Fortran doesn't do that
anymore.
👍 1
hello, just to let you know that I enjoyed your post and I learned a few things. Thanks
a million!
@evmcl, I too hate that id column on join tables, but many an ORM requires that
(some PHP frameworks, DJango, etc) and it always enrages me.
The use that @bowbahdoe mentions of adding data relating to the reasons for the
join would usually come up in modeling so one could specify and ID on those tables
when needed instead of universally.
[Link] 21/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
Nice article, I have one note on the Enum Tables , In my opinion if you use enums in a
supported programming language that corresponds to enums in the database, you
can avoid using enum types in the database, just use a regular data type like
varchar(n) this case is for enum types that are possible to be changed (delete and
add new ones), it's much easier to work with, you don't need to manually add data
there.
Thanks for sharing! One small remark on the soft delete column: if you already have
the updated_at timestamptz I'd make it a boolean since the timestamp information
can be retrieved from updated_at .
@placetobejohan Thats pretty ill-advised. There are other columns on there and the
updated_at gets set by a trigger. Changing some unrelated data retroactively
shouldn't alter knowledge about when like, a hold was released.
@bowbahdoe Hm I'd say it depends, as usual. In the case of having a reliable audit
system the deleted column shouldn't be any different from your other columns and
information about when it changed can be retrieved from there. If you want that
redundancy for robustness and/or easy access, or don't have (reliable) logging in
place, then I can see the need of using a timestamp.
[Link] 22/23
25.02.2026, 10:56 Life Altering Postgresql Patterns
In general, I love the terse crispness of the article.
Just came across the moddatetime extension that includes the moddatetime() trigger.
Probably useful for those updated_at columns.
Great set of tips. I guess there's an exception to every rule and I have the exception to
the 'use views sparingly' one.
I've an application that is designed to build views and use views to reference other
views - that's a large part of what it does What we've done is build application level
[Link] 23/23