There is a category of decision in software that looks small at the time and turns out to be large, and column naming is one of them. Not because the name of a column is intrinsically important, but because once a schema is in production, that name is everywhere: in every query, every route handler, every form field, every validation message, every log line, every API response. Renaming it is a migration, a code search, a review, a deployment, and a prayer that you found every reference. It is the kind of work that gets postponed indefinitely because the cost is high and the benefit is invisible to anyone who wasn't there when the name was chosen.

If you are using a generator that derives code from your schema - and I would argue it pays off, for the reasons covered elsewhere on this blog - the stakes are higher still. A naming decision made in the schema gets amplified across every artifact the generator produces, so getting it right before the generator runs matters more than it might seem, because getting it wrong means the wrong name propagates everywhere consistently, which is almost worse than inconsistency because it takes more effort to fix.


Make Inhouse Rules and Commit to Them

The first thing to accept is that your column naming conventions are yours and don't need to align with any external standard, any ORM expectation, or any API convention the community has settled on this year, because the people who will read these names are your team, working in your codebase, on your project, and optimizing for them is the only goal that matters.

What this means in practice is sitting down before the first migration runs and deciding on patterns, not column by column but by category: what do foreign keys look like, what do boolean flags look like, what do timestamps look like, what about codes and names and titles. Answer those questions once, write them down, and apply them consistently forever, because the consistency is the point. A codebase where every developer applied their own instincts to column naming is a codebase where you have to read each column to know what it is. A codebase with a convention is one where you know what a column is before you finish reading its name.


Examples of Patterns That Have Held Up

These are not a complete list and they are not meant to be - they are examples of the kind of categorical thinking that makes a schema readable, the patterns our own projects have settled on after enough iterations to trust them, and the goal is not to copy these suffixes but to arrive at a set of conventions with the same internal logic: consistent, recognizable, and decided before the first migration runs.

*_id for foreign keys. user_id, company_id, invoice_id. The suffix tells you this is a reference rather than a value, so you don't need to open the schema to know it points somewhere else.

*_at for timestamps. created_at, updated_at, deleted_at, disabled_at. The suffix tells you this is a moment in time when something happened, and it reads naturally in queries and in code because the grammar of the column name matches the grammar of what you say about it.

is_* and has_* for booleans. is_active, is_verified, has_subscription, has_children. The prefix makes the boolean nature of the column obvious without opening the schema, where is_ signals state and has_ signals possession, a small distinction that adds meaningful clarity when the column list is long.

*_code for short identifiers and enumerated values. country_code, status_code, currency_code. These are not names for human display; they are codes your application logic switches on. The suffix signals that - and it signals something else worth naming: a *_code column is often a soft foreign key. It relates to another table by value rather than by enforced constraint. country_code points to a countries table the same way country_id would, except the relationship is known by convention rather than declared to the database. That distinction matters when you're reading the schema: *_id means a hard reference with a constraint behind it; *_code means a known relationship, intentionally kept flexible.

*_name for human-readable labels. first_name, last_name, company_name. These are display values, searchable, what you show in a list.

*_title for longer descriptive strings with hierarchy implied. job_title, post_title, page_title. Similar to name but carrying a sense of heading or role, with a slight implication of something that belongs in an <h1> rather than a table cell.

None of these are invented, because they are patterns that emerge from building enough applications that you start noticing what makes a schema readable at a glance versus what makes you reach for the schema docs every time you write a query.


deleted_at and disabled_at Instead of Boolean Flags

This one is worth its own section because it runs counter to the instinct most developers have the first time they need to implement soft deletion.

The instinct is a boolean: is_deleted, is_active, flip it when the record is removed or disabled, filter on it everywhere, and while it works, it is a one-bit answer to a question that usually has more dimensions than one bit can hold.

A timestamp column like deleted_at or disabled_at gives you the same filter - deleted_at IS NULL for active records, deleted_at IS NOT NULL for deleted ones - but it also gives you when. When was this record deleted? When was this user disabled? That information is frequently useful for auditing, for support, for data analysis, and for the inevitable moment when someone asks "can we restore records deleted in the last thirty days." With a boolean, the answer is complicated. With a timestamp, the answer is a WHERE clause.

The other advantage is that IDs survive. A record marked as deleted is still in the table with its original ID intact. Every foreign key that pointed to it still resolves. Every audit log that referenced it still makes sense. Hard deletion breaks referential history in ways that tend to surface slowly and expensively. Soft deletion with a timestamp preserves it completely.

The filter discipline required - remembering to add WHERE deleted_at IS NULL to every query that should only return active records - is real overhead, but it is the kind of overhead a generator handles for you automatically if the convention is established. Reepolee addresses this through global scopes: named WHERE clauses defined once per table and applied automatically to generated queries. You define an "active" scope for a table as deleted_at IS NULL, set it as the default, and every generated list query applies it without you thinking about it. Scopes can also reference the current user's session - author_id = ::session.user.id - which means row-level visibility rules follow the same pattern: defined once, applied consistently, never forgotten in a query written at 11pm. That is a subject worth its own post, but the foundation is the naming convention: a generator can only understand deleted_at if you named it that way consistently.


snake_case in TypeScript, Even If the Community Disagrees

JavaScript and TypeScript use camelCase by convention, and most ORMs, most API design guides, and most JSON response examples you'll find online follow it, which makes the community expectation strong enough that going the other way requires a clear reason.

I use snake_case from the database all the way through to TypeScript, and I don't apologize for it, because the reason is simple and practical: user_id in TypeScript tells you immediately that this value came from your database, or is on its way there. It is not a language value, not a library type, not a third-party API response - it is yours. The underscore is a visual marker that says "this is our domain, our schema, our code." In a file that mixes your domain objects with library types and external API responses, that distinction is useful. You can tell at a glance which values you own and which you're borrowing.

The convention doesn't stop at column names, because functions follow it too, like get_user_by_id, validate_invoice_total, resolve_session_variables, and so do files and variables: global_scopes.ts, input-text.ree, const invoice_total, let current_user. The whole codebase speaks one dialect. When everything that belongs to your project follows snake_case and everything that comes from the language or a library follows camelCase, the boundary between your code and borrowed code is visible at a glance. That visibility has saved more debugging time than I can account for.

There is also something honest about it, because camelCase in your TS types that maps to snake_case in your DB requires a transformation layer - either explicit mapping or ORM magic that converts between the two silently. That transformation is complexity that solves a problem of appearances: it makes your TypeScript look conventional at the cost of hiding the mapping. snake_case throughout eliminates the transformation entirely. What you write in TypeScript is what the database stores. What the database returns is what you use directly. No mapping, no surprises, no layer to debug when something doesn't convert the way you expected.

There is also a mundane, practical argument that only becomes obvious the first time you need to find every column of a given type across the codebase. Search for _code and you get product_code, country_code, status_code - exactly the columns you named with that suffix, nothing else. Search for code and you get those plus every variable named code, every function parameter, every comment that happens to contain the word. The underscore is the differentiator. It makes your naming convention grep-able. The same applies to renaming: replacing product_code touches only what you intend. Replacing productCode in a mixed codebase risks catching camelCase values from libraries or external APIs that happen to share the name. Snake_case column names are precise search targets in a way that camelCase names, embedded in a language that uses camelCase for everything, simply are not.

This runs against community convention, and I want to be direct about why that doesn't concern me much. The community reflects the average of many projects with many different constraints, and no project is average. We tried snake_case end to end, observed what happened over two years of real maintenance, and formed a view, and if you land somewhere different after the same exercise, say so clearly, because that is how conventions improve - and the community will disagree regardless, while also changing its conventions more often than your schema does.


Before the Generator Runs

All of this comes before the first line of generated code. That is not a coincidence - it is the point. A generator is an amplifier. It takes the decisions encoded in your schema and propagates them into every artifact it produces. Good decisions, propagated consistently, produce a codebase that reads like it was designed. Poor decisions, propagated consistently, produce a codebase that is wrong in exactly the same way everywhere, which is harder to fix than a codebase that is wrong in varied ways, because the fix has to happen in the generator's logic rather than file by file.

Reepolee reads the schema as its single source of truth. It sees deleted_at and knows what it means. It sees user_id and knows it's a foreign key. It sees is_active and knows to render a checkbox. It generates snake_case TypeScript types that match your snake_case columns without transformation. The conventions you establish before running the generator are the conventions the generated code inherits - which means getting them right is not just about the schema, it is about every file that schema will ever produce.

Decide the names first, and the rest follows from there.