Blog
Designing Postgres Schemas in Prisma Without Painting Yourself Into a Corner
Prisma makes defining relations effortless, but the schema decisions you make early dictate query performance, migration pain, and type safety for the life of the project.
Prisma feels familiar to Mongoose users because both let you describe your data model declaratively and get typed access in return. The crucial difference is that Prisma sits on top of a relational database, so your schema choices map to real tables, foreign keys, and constraints that Postgres enforces rigorously. That rigor is a gift: the database becomes the guardian of your data integrity instead of trusting application code. But it also means careless modeling produces awkward joins, unwanted nullable columns, and migrations that fight you later. Understanding how Prisma translates its schema language into actual SQL lets you design models that are both ergonomic in TypeScript and sound at the database level. This piece walks through the modeling decisions that matter most when you are coming from a document database and learning to think in relations, normalization, and the constraints that keep production data honest under pressure.
How Prisma Maps To Tables
Each model in your Prisma schema becomes a table, each field becomes a column, and each scalar type maps to a specific Postgres type that you can see in the generated migration SQL. A String becomes text, an Int becomes integer, a DateTime becomes timestamp, and so on. Prisma also generates a fully typed client from this schema, so your TypeScript code knows exactly which fields exist and whether they are nullable. The mental model to internalize is that the schema file is the single source of truth: you edit it, run a migration, and Prisma both alters the database and regenerates the client types in lockstep. Unlike Mongoose where the schema lives only in application memory and the database happily stores anything, here the database structure and your code stay provably aligned, which catches whole categories of bugs at compile time.
Modeling One-To-Many Relations
The most common relation is one-to-many, like one user owning many posts. In Prisma you declare a posts field of type Post array on the user and an author field plus an authorId scalar on the post. The authorId column holds the foreign key, and Prisma adds a database-level foreign key constraint so Postgres rejects any post pointing at a nonexistent user. The relation field itself, author, exists only in Prisma for query convenience and creates no column. This separation between the virtual relation field and the real scalar foreign key trips up newcomers constantly, so it is worth saying plainly: the side holding the foreign key is the many side. Getting this right means your includes and nested writes work naturally, and Postgres enforces referential integrity for you instead of leaving orphaned records scattered through your tables.
Many-To-Many And Join Tables
When records relate many-to-many, like posts and tags where each post has several tags and each tag applies to many posts, you need a join table. Prisma offers an implicit form where you simply declare an array on both sides and it manages a hidden join table for you. This is convenient but limited, because you cannot attach extra data to the relationship itself. The moment you need attributes on the connection, like when a user joined an organization or what role they hold, you must model the join table explicitly as its own model with two foreign keys and your additional columns. Recognizing early whether a relationship will ever carry its own data saves a painful migration later. When in doubt on something that smells like a membership or an enrollment, model the join table explicitly from the start.
Nullability And Optional Fields
In Prisma a field is required unless you mark it optional with a question mark, which maps directly to whether the Postgres column allows NULL. This is a more consequential decision than it appears. A nullable column forces every consumer to handle the absent case, and in SQL it changes how comparisons and aggregations behave, since NULL is neither equal nor unequal to anything. Coming from Mongo, where missing fields are the norm, the instinct is to make everything optional, but that scatters null checks throughout your code and weakens the guarantees the database could otherwise provide. Prefer required fields with sensible defaults where possible, reserving nullability for values that are genuinely unknown rather than merely not-yet-set. Each optional field is a small ongoing tax on every query and every TypeScript branch that touches it downstream.
Indexes And Unique Constraints
Prisma lets you declare indexes and unique constraints right in the schema with attributes, and you absolutely should rather than adding them by hand later. A unique attribute on an email field becomes a unique constraint that Postgres enforces, preventing duplicate accounts at the database level no matter how many concurrent signups race. A composite unique constraint across two fields, like organizationId and slug, enforces uniqueness within a scope. The index attribute creates a plain B-tree index to accelerate lookups on columns you filter by frequently, mirroring the indexing decisions discussed for raw Postgres. Declaring these in the schema keeps them version-controlled and applied consistently across every environment through migrations. Forgetting a unique constraint and trying to enforce uniqueness only in application code is a reliable way to end up with duplicate rows under concurrency.
Enums And Constrained Values
When a column should hold only a fixed set of values, like an order status of pending, shipped, or delivered, model it as a Prisma enum rather than a free-form string. Prisma creates a native Postgres enum type, so the database itself rejects any value outside the allowed set, and your TypeScript gets a union type giving you exhaustive checks in switch statements. This is far stronger than storing a string and hoping every code path uses valid values. The one caveat is that altering an enum, especially removing a value, requires a migration that can be fussy if existing rows use the value you are dropping. For sets that change rarely, native enums are excellent; for sets that grow often or carry their own metadata, a separate lookup table with a foreign key is the more flexible long-term choice.
Migrations And Schema Evolution
Prisma Migrate turns schema changes into versioned SQL files you commit alongside your code, giving you a reproducible history of how the database evolved. In development you iterate with migrate dev, which creates and applies migrations and warns you about destructive changes like dropping a column with data. In production you run migrate deploy, which applies pending migrations without prompting. The discipline that pays off is reviewing the generated SQL before applying it, especially for changes that can lose data or lock large tables. Renaming a column, for instance, Prisma may interpret as a drop plus an add unless you guide it, silently discarding data. Treating migrations as real code to be read and reviewed, rather than magic that just works, prevents the worst production incidents and keeps every environment converging on the same known database structure over time.
Avoiding The N Plus One Trap
A subtle performance killer in any ORM is the N plus one query, where fetching a list and then looping to load each item's relations fires one query per row. Prisma makes the relation access so ergonomic that it is easy to write code that loads a hundred posts then issues a hundred separate queries for each author. The fix is to load relations eagerly using include or select in the original query, letting Prisma fetch the related data in a small fixed number of queries instead. For deeply nested data, be deliberate about how much you pull, since over-fetching wide relation trees is its own problem. Prisma also offers relation loading strategies you can tune. The habit to build is inspecting the actual SQL Prisma generates during development, so you catch query explosions before they reach production and degrade under real data volume.