<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mikhail Shytsko</title>
    <description>The latest articles on DEV Community by Mikhail Shytsko (@mikh-shytsko).</description>
    <link>https://dev.to/mikh-shytsko</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3948102%2Fa736e791-77e9-40e8-b6df-1059059f6f5e.jpg</url>
      <title>DEV Community: Mikhail Shytsko</title>
      <link>https://dev.to/mikh-shytsko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mikh-shytsko"/>
    <language>en</language>
    <item>
      <title>Faker Doesn't Know What Your Data Means</title>
      <dc:creator>Mikhail Shytsko</dc:creator>
      <pubDate>Sat, 11 Jul 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/mikh-shytsko/faker-doesnt-know-what-your-data-means-4l0b</link>
      <guid>https://dev.to/mikh-shytsko/faker-doesnt-know-what-your-data-means-4l0b</guid>
      <description>&lt;p&gt;A single row of Faker output looks flawless. Call &lt;code&gt;faker.name()&lt;/code&gt;, &lt;code&gt;faker.email()&lt;/code&gt;, and &lt;code&gt;faker.date_time_this_decade()&lt;/code&gt;, and back come &lt;code&gt;Gloria Hettinger&lt;/code&gt;, &lt;code&gt;gloria.hettinger@example.org&lt;/code&gt;, and a timestamp somewhere in 2023, each value drawn from a distribution that fits its column. Stack ten thousand rows like it and every column still survives inspection on its own.&lt;/p&gt;

&lt;p&gt;What none of those rows carry is a relationship to the row beside them, or to the row in the next table over. That gap is the whole subject here, because realistic test data usually gets treated as a property of columns (realistic names, well-formed emails) when the realism that actually matters lives in the joins between them. Faker is the convenient example rather than the target; the same blind spot shows up in any generator that fills one column at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What plausible hides
&lt;/h2&gt;

&lt;p&gt;Generate a &lt;code&gt;users&lt;/code&gt; table and an &lt;code&gt;orders&lt;/code&gt; table in two separate Faker loops and each table looks right in isolation. The &lt;code&gt;orders&lt;/code&gt; table has a &lt;code&gt;user_id&lt;/code&gt; column full of integers in a believable range, none of them null, all of them the correct type. Whether any of those integers points at a user you actually generated is a question Faker was never asked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8cesrvstuti7lh23txu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8cesrvstuti7lh23txu.png" alt="Faker-generated users and orders rows where order user_id values match no generated user id" width="800" height="679"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshot above is the ordinary result: five users numbered 1 through 5, five orders pointing at user IDs in the hundreds, a child table that inserts cleanly only because no constraint stood in the way. Add a real &lt;code&gt;REFERENCES users(id)&lt;/code&gt; and the same seed script dies on the first orphaned row instead.&lt;/p&gt;

&lt;p&gt;Coherent generation produces the opposite artifact. Every &lt;code&gt;user_id&lt;/code&gt; in &lt;code&gt;orders&lt;/code&gt; gets chosen from the set of users that exist, so a join across the two tables returns every row rather than silently dropping the orphans.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0erixb62wbomeimnzbmr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0erixb62wbomeimnzbmr.png" alt="psql JOIN across Seedfast-seeded users and orders showing every foreign key resolving" width="800" height="887"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting that join to resolve completely is the floor, and clearing it proves almost nothing on its own. It is necessary, and nowhere near sufficient, which is the rest of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faker generates one column at a time, on purpose
&lt;/h2&gt;

&lt;p&gt;This isn't a defect in Faker, it's the shape of the API. Each provider call returns a value for one domain from a source of randomness that holds no state about the calls before it or the columns around it, which is exactly what makes the library fast, composable, and easy to reason about. As the community puts it, "Faker generates individual field values independently, with no concept of relationships." A &lt;code&gt;first_name&lt;/code&gt; and a &lt;code&gt;last_name&lt;/code&gt; in the same row have no idea they share a row, and an &lt;code&gt;email&lt;/code&gt; two columns over won't contain either of them unless you wire that up by hand.&lt;/p&gt;

&lt;p&gt;For a great deal of work, that is the right tool. A unit test that needs one well-formed email, a single flat table with no foreign keys, a throwaway fixture you discard right after the assertion, none of these need cross-row reasoning, and reaching for something heavier only adds ceremony. Once an "AI" label gets stapled onto that same column-by-column approach, the shape underneath hasn't moved, and separating a genuinely schema-aware tool from a chat wrapper is exactly what the &lt;a href="https://seedfa.st/blog/best-ai-test-data-generator" rel="noopener noreferrer"&gt;best AI test data generator&lt;/a&gt; comparison walks through. The trouble starts when the data has to hold together, not merely look right column by column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where valid and real diverge
&lt;/h2&gt;

&lt;p&gt;Column values can each be individually plausible and still be collectively wrong, and the clearest case is numeric distribution. Faker will fill an &lt;code&gt;amount&lt;/code&gt; column with values spread uniformly across a range, and uniform is precisely what money is not.&lt;/p&gt;

&lt;p&gt;Real transaction amounts, account balances, invoice totals, and most quantities that span several orders of magnitude follow Benford's law, their leading digit landing on 1 about 30 percent of the time and on 9 under 5 percent. Balances skew log-normal, a few large accounts sitting over a long tail of small ones. Per-user activity skews Zipfian, a handful of power users producing most of the rows while everyone else trails off. A uniform column reproduces none of these shapes.&lt;/p&gt;

&lt;p&gt;The distribution matters because your code and your database both read it. Postgres plans a query from the statistics it has gathered on a column, so a flat spread over ten thousand distinct values can steer the planner toward a sequential scan where production, with its skew, would reach for an index (or the reverse). A &lt;code&gt;SUM&lt;/code&gt; over uniform amounts settles near a total that real data would never land on. Anything that ranks or thresholds, a top-accounts panel or a fraud rule that fires above a percentile, behaves against this data in a way it never will against the real thing. This failure is semantic rather than structural, the rows staying perfectly valid while meaning something the domain would never produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  A foreign key checks existence, not meaning
&lt;/h2&gt;

&lt;p&gt;Coherence has a second axis, one running across rows the schema connects rather than across columns in a single row. A foreign key enforces that the referenced row exists, and enforces nothing about whether the reference makes any sense.&lt;/p&gt;

&lt;p&gt;So an &lt;code&gt;orders&lt;/code&gt; row can point at a genuine customer and still carry a &lt;code&gt;placed_at&lt;/code&gt; earlier than that customer's &lt;code&gt;created_at&lt;/code&gt;, an order placed by an account that didn't exist yet. A &lt;code&gt;subscriptions&lt;/code&gt; row can reference a billing period that never ran. A &lt;code&gt;shipments&lt;/code&gt; row can belong to an order nobody ever paid for. Each of these satisfies &lt;a href="https://seedfa.st/blog/referential-integrity" rel="noopener noreferrer"&gt;referential integrity&lt;/a&gt; while being obvious nonsense, the sort a reviewer catches at a glance and a constraint check is built to ignore.&lt;/p&gt;

&lt;p&gt;This is how a suite stays green against seeded rows and still sails past the bug a coherent dataset would have exposed. Let your &lt;a href="https://seedfa.st/blog/e2e-test-fixtures" rel="noopener noreferrer"&gt;E2E fixtures&lt;/a&gt; hold orders older than their own customers, and any test that should exercise an account-age or tenure calculation never meets a row that would break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What realistic test data actually requires
&lt;/h2&gt;

&lt;p&gt;Put the two axes together and a working definition falls out. Realistic test data has to hold cross-column coherence inside each row, cross-row coherence across every table it connects to, and distributions shaped like the domain rather than like a random number generator. Meeting all three by hand means encoding the schema's insert order, its temporal rules, and its statistical profile into fixture code, then re-encoding them after every migration that moves any of it.&lt;/p&gt;

&lt;p&gt;That maintenance burden is why most &lt;a href="https://seedfa.st/blog/data-seeding-tools" rel="noopener noreferrer"&gt;data seeding tools&lt;/a&gt; stop at column values and hand the relational and semantic work back to you. Closing the gap means reading the schema itself, on every run, instead of a snapshot that drifts the moment someone ships an &lt;code&gt;ALTER TABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Seedfast does that read live. It connects to your Postgres, reads the schema, and generates values inferred from each column's role, so a &lt;code&gt;placed_at&lt;/code&gt; lands after the customer's &lt;code&gt;created_at&lt;/code&gt; and an &lt;code&gt;amount&lt;/code&gt; column comes out shaped like money instead of like &lt;code&gt;random()&lt;/code&gt;. You describe the scenario in plain language and it fills the database in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;seedfast seed &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"500 customers with orders, payments, and realistic balances"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside CI it runs as a pipeline step against a connection string, and inside an AI coding agent it runs through an MCP server, the agent calling the &lt;code&gt;seedfast_run&lt;/code&gt; tool rather than writing a throwaway seed script that loses track of the relationships three tables deep. The broader &lt;a href="https://seedfa.st/blog/test-data-generation" rel="noopener noreferrer"&gt;comparison of test data generation methods&lt;/a&gt; covers where this sits next to SQL dumps and factory code, and the playbook on how to &lt;a href="https://seedfa.st/blog/generate-test-data-with-ai" rel="noopener noreferrer"&gt;generate test data with AI&lt;/a&gt; covers the agent workflow in depth. The same coherence argument holds past a test suite: &lt;a href="https://seedfa.st/blog/demo-data-generator" rel="noopener noreferrer"&gt;convincing demo data&lt;/a&gt; has to satisfy it too, just for a person watching the screen instead of an assertion reading a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Faker output the same thing as synthetic data?
&lt;/h3&gt;

&lt;p&gt;Faker output is one narrow kind of synthetic data, not the whole category. Synthetic data means any data generated rather than sampled from production, which spans everything from a single fabricated email to a fully coherent multi-table dataset with production-shaped distributions; the &lt;a href="https://seedfa.st/blog/synthetic-test-data-generation" rel="noopener noreferrer"&gt;synthetic test data generation&lt;/a&gt; page walks that spectrum end to end. Faker occupies the column-independent end of that range, so "faker vs synthetic data" compares a specific library against a broad category rather than weighing two like-for-like options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a Faker-seeded database pass tests but break in staging?
&lt;/h3&gt;

&lt;p&gt;A Faker-seeded database passes tests because most assertions check the happy path at tiny volume, where individual column values are all that gets exercised. Staging runs real queries against real row counts, where insert order, cross-row coherence, and distribution shape all begin to matter, and where the query planner picks different plans than it did against a handful of uniform rows. The data was never wrong in a way a small green test could see.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is cross-column coherence in test data?
&lt;/h3&gt;

&lt;p&gt;Cross-column coherence is the property that the values in a row agree with each other and with the rows they reference, so an order lands after the account that placed it existed and an invoice total matches the sum of its line items. Column-independent generators cannot produce it, because each value is drawn without reference to the others. The line it draws is between data that is individually plausible and data that is jointly true.&lt;/p&gt;

&lt;h3&gt;
  
  
  When does Faker stop being enough?
&lt;/h3&gt;

&lt;p&gt;Faker stops being enough the moment correctness depends on relationships between rows rather than the format of a single value. As long as what you need is a well-formed value in an isolated column, it stays the right tool. Once the thing under test spans a foreign key, relies on a signup-before-order ordering, or reads a production-shaped distribution, test data quality turns into a schema-level property that a field-at-a-time generator can't reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard for realistic test data moved
&lt;/h2&gt;

&lt;p&gt;For years, filling a database with per-column fakes was simply what generating test data meant, and for the narrow schemas of that era it was mostly fine. Schemas since then have grown wider and deeper, tests have crept closer to production shapes, and query planners have gotten better at exploiting the very statistics that uniform data fakes, so the bar for what counts as realistic has quietly risen from plausible columns to coherent, correctly-shaped, relationally-valid rows. A generator that can't read your schema can't clear that bar, however convincing its street addresses look. Seedfast reads the schema on every run and generates data that holds together across every table, and its &lt;a href="https://seedfa.st/pricing" rel="noopener noreferrer"&gt;30-day free trial&lt;/a&gt; takes no card.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://seedfa.st/blog/realistic-test-data" rel="noopener noreferrer"&gt;seedfa.st&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your Test Data Is Type-Correct and Still Invalid: 6 Postgres Schema Features Generators Skip</title>
      <dc:creator>Mikhail Shytsko</dc:creator>
      <pubDate>Mon, 01 Jun 2026 19:25:26 +0000</pubDate>
      <link>https://dev.to/mikh-shytsko/your-test-data-is-type-correct-and-still-invalid-6-postgres-schema-features-generators-skip-4c7m</link>
      <guid>https://dev.to/mikh-shytsko/your-test-data-is-type-correct-and-still-invalid-6-postgres-schema-features-generators-skip-4c7m</guid>
      <description>&lt;h1&gt;
  
  
  Your Test Data Is Type-Correct and Still Invalid: 6 Postgres Schema Features Generators Skip
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Composite primary keys, partial unique indexes, cross-column CHECK constraints, JSONB shape, &lt;code&gt;GENERATED ALWAYS&lt;/code&gt; columns, and row-level security all reject type-correct data, because column types are not what your schema actually enforces.&lt;/p&gt;

&lt;p&gt;A few months ago I watched a seed run finish with a clean green summary: every column populated, every type correct, a few thousand rows inserted. The first integration test then failed on an &lt;code&gt;INSERT&lt;/code&gt; the application itself ran. The generated data was valid the way a sentence with correct grammar can still be a lie. Each value matched its column type. The combination of values broke a constraint the generator never looked at.&lt;/p&gt;

&lt;p&gt;That gap has a simple cause. A column type is a per-column promise: this is an &lt;code&gt;integer&lt;/code&gt;, this is &lt;code&gt;text&lt;/code&gt;, this is &lt;code&gt;jsonb&lt;/code&gt;. Most of what a real schema enforces is not per-column. It lives one level up: across columns in a row, across rows in a table, or across the role doing the writing. A generator that thinks in columns produces data that is type-correct and still invalid.&lt;/p&gt;

&lt;p&gt;Here are six places that gap shows up in Postgres, what each one actually enforces, and a query you can run to see whether your own generated data respects it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Composite primary keys: the tuple is unique, not the columns
&lt;/h2&gt;

&lt;p&gt;A composite primary key enforces uniqueness over the &lt;em&gt;combination&lt;/em&gt; of columns, not over each column on its own. The docs put it plainly: "the combination of values in the indicated columns is unique across the whole table, though any one of the columns need not be (and ordinarily isn't) unique."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;course_id&lt;/span&gt;  &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A column-by-column generator handles this badly in two opposite ways. It either treats &lt;code&gt;student_id&lt;/code&gt; as a unique key and never lets a student enroll in two courses, or it generates both columns independently and produces duplicate &lt;code&gt;(student_id, course_id)&lt;/code&gt; pairs that collide on insert. Both are wrong, and the second one only surfaces once enough rows exist to cause a collision, usually in CI rather than on a laptop with ten rows.&lt;/p&gt;

&lt;p&gt;There is a second trap here: a primary key forces every participating column to &lt;code&gt;NOT NULL&lt;/code&gt;. Adding a primary key "will force the column(s) to be marked &lt;code&gt;NOT NULL&lt;/code&gt;," so a generator that emits an occasional &lt;code&gt;NULL&lt;/code&gt; for a nullable-looking integer will fail against a column it didn't realize was mandatory.&lt;/p&gt;

&lt;p&gt;To count the duplicate tuples your data would reject:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;enrollment&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns any rows, your generator is treating a tuple constraint as a set of column constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Partial unique indexes: uniqueness with a WHERE clause
&lt;/h2&gt;

&lt;p&gt;This is the one I see missed most often, because it isn't a constraint at all. It's an index, and generators that introspect constraints never see it. A partial unique index enforces uniqueness "among the rows that satisfy the index predicate, without constraining those that do not."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;one_active_subscription&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That index says: a user may have many subscriptions, but only one &lt;em&gt;active&lt;/em&gt; one. A generator that produces realistic-looking subscription histories, several rows per user with a mix of statuses, will happily hand two of them &lt;code&gt;status = 'active'&lt;/code&gt; and hit a unique violation that exists only for the active subset. Nothing in the column types hints at it. Nothing in the foreign keys hints at it. The rule lives in a &lt;code&gt;WHERE&lt;/code&gt; clause on an index.&lt;/p&gt;

&lt;p&gt;Diagnostic, to find the predicate subset that would collide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing: you cannot express this as a table constraint with &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT&lt;/code&gt;. Partial uniqueness only exists through &lt;code&gt;CREATE UNIQUE INDEX ... WHERE&lt;/code&gt;, which is exactly why constraint-only introspection misses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CHECK constraints: the rule that spans two columns
&lt;/h2&gt;

&lt;p&gt;A CHECK constraint can reference more than one column in the same row, and that cross-column form is where generated data falls down. A per-column generator picks each value in isolation, so it has no way to satisfy a rule that relates two of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;starts_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ends_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate &lt;code&gt;starts_at&lt;/code&gt; and &lt;code&gt;ends_at&lt;/code&gt; independently from a plausible date range and roughly half your rows will have an end before the start. Every value is a valid timestamp. The row is still rejected.&lt;/p&gt;

&lt;p&gt;Two details that bite specifically during seeding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NULL passes the check.&lt;/strong&gt; A CHECK is satisfied when its expression is true &lt;em&gt;or&lt;/em&gt; null. The columns above are &lt;code&gt;NOT NULL&lt;/code&gt;, so it can't bite in this example, but the moment a checked column is nullable, &lt;code&gt;CHECK (ends_at &amp;gt; starts_at)&lt;/code&gt; passes on every row where &lt;code&gt;ends_at&lt;/code&gt; is null. On a nullable schema, write the diagnostic as &lt;code&gt;WHERE col IS NULL OR NOT (...)&lt;/code&gt; so those rows aren't silently skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NOT VALID&lt;/code&gt; constraints lie about coverage.&lt;/strong&gt; A constraint added &lt;code&gt;NOT VALID&lt;/code&gt; is enforced for new rows immediately but never checked against existing rows until you run &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;. If you seed into a table that has a &lt;code&gt;NOT VALID&lt;/code&gt; check, the seed is held to the rule even though the old data isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the constraint expression as a query and count the violations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. JSONB: the column type that enforces almost nothing
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;jsonb&lt;/code&gt; column guarantees one thing: the value is valid JSON. It does not enforce keys, required fields, or value types. The structure "is typically unenforced," in the documentation's words. The shape your application depends on lives entirely in application code, not in the column.&lt;/p&gt;

&lt;p&gt;This is a problem for generators in both directions. A naive generator drops &lt;code&gt;'{}'&lt;/code&gt; or a random string-keyed blob into the column, it's valid JSON, the insert succeeds, and the first code path that reads &lt;code&gt;payload-&amp;gt;&amp;gt;'amount'&lt;/code&gt; gets null and breaks far away from the cause. A generator that knows the column is &lt;code&gt;jsonb&lt;/code&gt; still has no schema to generate against, because Postgres never had one to give it.&lt;/p&gt;

&lt;p&gt;You can pull &lt;em&gt;some&lt;/em&gt; of the contract back into the database with a CHECK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;events_payload_shape&lt;/span&gt;
    &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'number'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The explicit &lt;code&gt;payload ? 'amount'&lt;/code&gt; test is doing real work. Drop it and a row with no &lt;code&gt;amount&lt;/code&gt; key passes anyway, because &lt;code&gt;jsonb_typeof(payload -&amp;gt; 'amount')&lt;/code&gt; returns SQL NULL on a missing key, and a CHECK is satisfied by NULL. It's the same trap as Section 3, hiding in a different operator.&lt;/p&gt;

&lt;p&gt;Diagnostic, with absence treated as a violation rather than skipped over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'number'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IS DISTINCT FROM&lt;/code&gt; is the key move: unlike &lt;code&gt;= 'number'&lt;/code&gt;, it returns true (a violation) when the left side is NULL, so a missing &lt;code&gt;amount&lt;/code&gt; is counted instead of silently passing. If you have no such CHECK and no such query, your generated JSON is "valid" only in the sense that Postgres accepts it, not in the sense that your application will.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Generated columns: the value you must not write
&lt;/h2&gt;

&lt;p&gt;A generated column is computed from other columns, and the docs are blunt about it: "A generated column cannot be written to directly." Try to insert one anyway and Postgres returns &lt;code&gt;cannot insert a non-DEFAULT value into column&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;line_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;    &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unit_price&lt;/span&gt;  &lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_price&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unit_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A generator that builds its column list by reading every column in the table will try to insert &lt;code&gt;total_price&lt;/code&gt; and fail on the first row. A generator that simply skips unknown columns might omit a column that &lt;em&gt;isn't&lt;/em&gt; generated. Knowing which columns are write-protected is the difference, and it is not visible from the column type. As far as the type system is concerned, &lt;code&gt;total_price&lt;/code&gt; is just a &lt;code&gt;numeric&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's a version trap here worth flagging, because it changed recently. &lt;code&gt;STORED&lt;/code&gt; generated columns arrived in Postgres 12, and through Postgres 17 the keyword was &lt;strong&gt;required&lt;/strong&gt;: omit it and you get a syntax error, so every generated column on those versions is stored. Postgres 18 made the keyword optional and &lt;strong&gt;virtual&lt;/strong&gt; the default, so an unqualified &lt;code&gt;GENERATED ALWAYS AS (...)&lt;/code&gt; is now a virtual column computed at read time. That bites on upgrade. Virtual columns can't be indexed yet (planned for a later release), so a column that used to be &lt;code&gt;STORED&lt;/code&gt; and indexed becomes un-indexable the moment someone drops the keyword. Always write &lt;code&gt;STORED&lt;/code&gt; explicitly when you mean stored. The no-direct-write restriction applies to both kinds.&lt;/p&gt;

&lt;p&gt;List the generated columns a writer must never populate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_generated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generation_expression&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'line_items'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_generated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ALWAYS'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Row-level security: "valid data" depends on who is writing
&lt;/h2&gt;

&lt;p&gt;Row-level security is the feature that breaks the assumption underneath all the others, namely that whether a row is valid is a property of the row. Under RLS it is a property of the row &lt;em&gt;and the role doing the write&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WITH CHECK&lt;/code&gt; clause is applied to every &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt;: a row whose &lt;code&gt;tenant_id&lt;/code&gt; doesn't match the current tenant is rejected even though every value in it is type-correct and constraint-clean. Two facts compound this for anyone generating data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The table owner bypasses RLS by default.&lt;/strong&gt; Seed as the owner and every policy stays silent, so the data looks fine until the application connects as a normal role and the same rows turn out to be invisible or the same inserts get refused. Unless the table has &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;, your seed never exercised the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Superusers and &lt;code&gt;BYPASSRLS&lt;/code&gt; roles skip policies entirely.&lt;/strong&gt; Seeding scripts often connect as exactly these privileged roles, so generate through one and you've tested nothing about the rules that govern real traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diagnostic, to see which tables have policies your seed role might be quietly bypassing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those tables matter and your generator connects as the owner, your "valid" rows were never measured against the rules that decide validity in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tools actually sit
&lt;/h2&gt;

&lt;p&gt;None of this means generated test data is a bad idea. It means the question to ask a generator is not "does it produce realistic values" but "how much of the schema does it treat as input." Roughly three tiers exist today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free and DIY.&lt;/strong&gt; &lt;a href="https://github.com/joke2k/faker" rel="noopener noreferrer"&gt;Faker&lt;/a&gt;, ORM seeders, and hand-written scripts generate values per column. Relationships, table-level constraints, and the features above stay your job, in your code, kept in sync by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-aware generators.&lt;/strong&gt; A newer middle tier reads the schema and treats its structure as a first-class input. &lt;a href="https://github.com/nucleuscloud/neosync" rel="noopener noreferrer"&gt;Neosync&lt;/a&gt; and &lt;a href="https://seedfa.st/" rel="noopener noreferrer"&gt;Seedfast&lt;/a&gt; are two of several tools that take this approach in different ways. The honest trade-off is that "schema-aware" is a spectrum. Foreign keys and &lt;code&gt;NOT NULL&lt;/code&gt; are widely handled, while partial unique indexes, cross-column CHECKs, and RLS are exactly where coverage varies between tools, so it's worth testing each against your own constraints rather than trusting the label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise TDM platforms.&lt;/strong&gt; Anonymization and masking suites that transform production data. They cover a lot, at the cost of needing production access and a setup measured in weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful question isn't which price tier a tool sits in, it's how far past column types it actually looks. Run your real constraints through any candidate before you trust it, whatever tier it claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your schema relies on...&lt;/th&gt;
&lt;th&gt;Before trusting generated rows, check...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Composite primary keys&lt;/td&gt;
&lt;td&gt;duplicate tuples, and &lt;code&gt;NULL&lt;/code&gt; in any PK column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial unique indexes&lt;/td&gt;
&lt;td&gt;uniqueness only within the predicate subset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-column CHECKs&lt;/td&gt;
&lt;td&gt;the expression as a &lt;code&gt;WHERE NOT (...)&lt;/code&gt; query, adding &lt;code&gt;col IS NULL OR&lt;/code&gt; for nullable columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSONB shape&lt;/td&gt;
&lt;td&gt;required keys and value types via a CHECK or a probe query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GENERATED ALWAYS&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;the writer skips them;&lt;code&gt;STORED&lt;/code&gt; is explicit on PG 18+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row-level security&lt;/td&gt;
&lt;td&gt;the seed role isn't the owner or &lt;code&gt;BYPASSRLS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When none of this matters
&lt;/h2&gt;

&lt;p&gt;Plenty of test data doesn't need to clear this bar. A unit test that touches one flat table is better served by a three-line fixture than by anything that introspects a schema. Not every column needs realistic values; sometimes &lt;code&gt;'x'&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; are the honest choice because the test doesn't care. And there's a class of rules no generator can infer, the domain invariants that live only in your head or your application, like "a refund row must point at a captured payment." Those you encode yourself, or you assert in the test.&lt;/p&gt;

&lt;p&gt;Generators aren't the problem here. The trouble is that "the insert succeeded" and "the data is valid" are two different claims, and the gap between them lives in exactly the schema features that never show up in a column type. Run the six queries above against your own generated data before you trust the green summary.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>supabase</category>
      <category>sql</category>
    </item>
    <item>
      <title>Database Seeding: What It Is, Methods, and Best Practices</title>
      <dc:creator>Mikhail Shytsko</dc:creator>
      <pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/mikh-shytsko/database-seeding-what-it-is-methods-and-best-practices-28n2</link>
      <guid>https://dev.to/mikh-shytsko/database-seeding-what-it-is-methods-and-best-practices-28n2</guid>
      <description>&lt;p&gt;&lt;strong&gt;Database seeding is the process of populating a database with an initial set of rows before an application runs against it, so the schema isn't just structurally valid but contains the reference data, demo accounts, and test fixtures the application expects to find.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've ever written an INSERT statement into a file called &lt;code&gt;seed.sql&lt;/code&gt; or &lt;code&gt;seed.ts&lt;/code&gt;, you've done it. This guide starts there and works forward through ORM seeders, generated data, and what to do when none keep up — including &lt;a href="https://seedfa.st/blog/vibe-coding-database" rel="noopener noreferrer"&gt;the database part of vibe coding&lt;/a&gt;, where an AI tool scaffolds the whole app but leaves the seed file to you. The schema-aware approach at the end is what &lt;a href="https://seedfa.st/" rel="noopener noreferrer"&gt;Seedfast&lt;/a&gt; was built around, best read after you've watched the conventional methods break.&lt;/p&gt;

&lt;p&gt;If you're already past the "what is it" stage and comparing concrete tools (Laravel vs Prisma vs Drizzle vs standalone seeders), jump to the &lt;a href="https://seedfa.st/blog/database-seeder" rel="noopener noreferrer"&gt;database seeder tool comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Database seeding starts simple (a SQL file) and gets complicated fast as schemas grow; the problem isn't generating values, it's keeping seed data valid across migrations&lt;/li&gt;
&lt;li&gt;Every major ORM has a built-in seeder (Prisma, Laravel, EF Core), but the data and relationships are defined manually, which means they break on schema changes just like raw SQL&lt;/li&gt;
&lt;li&gt;Keeping foreign keys valid is what makes seeding hard at scale. Once tables form cycles or deep chains, hand-ordered inserts stop working, and the fix is a tool that keeps &lt;a href="https://seedfa.st/blog/referential-integrity" rel="noopener noreferrer"&gt;referential integrity&lt;/a&gt; intact for you instead of leaving it to a hand-maintained file&lt;/li&gt;
&lt;li&gt;If your seed file is the bottleneck, the next step is generation, meaning tools that read the schema directly and produce data that satisfies constraints, so the seed doesn't need to be maintained alongside the migrations&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is Database Seeding?
&lt;/h2&gt;

&lt;p&gt;Database seeding is the process of populating a database with an initial set of data. The term comes from agriculture, where nothing grows until something is planted, and in databases it means inserting a starting dataset so the application has something to work with, whether that's three admin users for local development, a thousand products for a staging demo, or a connected dataset across 40 tables for integration tests.&lt;/p&gt;

&lt;p&gt;There are two kinds of seed data, and teams routinely conflate them. Reference data lives in production and covers roles, categories, feature flags, country codes, and default settings; it's part of the application's contract and changes rarely. Development and test data is the opposite, fake users and sample orders that exist only outside production. Getting that fabricated data to cohere across columns is its own problem, and &lt;a href="https://seedfa.st/blog/realistic-test-data" rel="noopener noreferrer"&gt;what Faker-style generation misses&lt;/a&gt; is where hand-written fixtures look plausible without holding up.&lt;/p&gt;

&lt;p&gt;The confusion starts when teams use one mechanism for both. A &lt;code&gt;seed.sql&lt;/code&gt; that inserts admin roles AND a thousand fake users couples two things that change at completely different rates, the roles almost never and the fake users constantly, so every later migration has to drag both through the same increasingly brittle file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Seed a Database?
&lt;/h2&gt;

&lt;p&gt;A freshly migrated database has tables but no rows. Every dashboard renders an empty state, every list view returns nothing, and every test that depends on a user or a product fails before it starts. Seeding solves three concrete problems at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local development gets a populated UI, so new devs can click through real flows on day one instead of staring at empty tables.&lt;/li&gt;
&lt;li&gt;Integration and end-to-end tests get data to assert against, since a test that checks "user sees their last 10 orders" needs 10 orders in the database before it runs.&lt;/li&gt;
&lt;li&gt;Production gets the reference rows the app needs to boot cleanly; without them the application won't start on day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip it and every developer writes the same INSERT statements by hand, every CI run starts from a different database state, and onboarding degrades into "ask Marcus for a dump".&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Seeding vs Database Migration
&lt;/h2&gt;

&lt;p&gt;Migrations and seeds are often confused because they both run against the database before the application does. They are not the same thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Migration&lt;/th&gt;
&lt;th&gt;Seed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it does&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Changes structure (tables, columns, indexes)&lt;/td&gt;
&lt;td&gt;Inserts data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Versioned?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — strict order&lt;/td&gt;
&lt;td&gt;No — should be idempotent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runs in production?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Always, on every deploy&lt;/td&gt;
&lt;td&gt;Reference data only; never test data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Repeatable?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Each migration runs once&lt;/td&gt;
&lt;td&gt;Should produce the same end state on every run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule of thumb is that if you'd lose work without it, it's a migration, and if you'd lose realism without it, it's a seed. Mixing the two, whether that's test data inside a migration or schema changes inside a seed, tangles deploys in ways that surface at the worst possible moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting Simple: the seed.sql File
&lt;/h2&gt;

&lt;p&gt;Every database seeding journey starts here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'viewer'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Engineering'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Design'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bob@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;psql -f seed.sql&lt;/code&gt; and your database has data, deterministically and under version control. For a 5-table schema that changes quarterly, this is fine. For writing a Postgres seed file from scratch, see &lt;a href="https://seedfa.st/blog/seed-database" rel="noopener noreferrer"&gt;Seed Database: PostgreSQL Step-by-Step Guide&lt;/a&gt;; for the failure modes that quietly break one, from FK ordering and idempotency to the migration that invalidates it, see &lt;a href="https://seedfa.st/blog/postgres-seed-script" rel="noopener noreferrer"&gt;Postgres Seed Script: Build One That Lasts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The trouble starts the day someone runs &lt;code&gt;ALTER TABLE users ADD COLUMN department_id INTEGER NOT NULL REFERENCES departments(id)&lt;/code&gt;. The seed file doesn't insert departments, so the INSERT into &lt;code&gt;users&lt;/code&gt; fails, and someone has to fix it, test it, and commit it. Multiply that across 30 tables and monthly migrations, and &lt;a href="https://seedfa.st/blog/seed-file-maintenance" rel="noopener noreferrer"&gt;the maintenance cost becomes its own workstream&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How ORMs Handle Database Seeding
&lt;/h2&gt;

&lt;p&gt;Every major ORM ships a seeding mechanism. The syntax varies from one framework to the next, but the pattern underneath is identical, since you're writing code that creates records through the ORM's API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prisma&lt;/strong&gt; puts seeds in a TypeScript file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@prisma/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PrismaClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Engineering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Register the script under `prisma.seed` in package.json or prisma.config.ts,&lt;/span&gt;
&lt;span class="c1"&gt;// then run: npx prisma db seed&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Laravel&lt;/strong&gt; uses seeder classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// database/seeders/DatabaseSeeder.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Team&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Engineering'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'team_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Run: php artisan db:seed&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;EF Core&lt;/strong&gt; (version 9 and later) uses &lt;code&gt;UseSeeding&lt;/code&gt; and &lt;code&gt;UseAsyncSeeding&lt;/code&gt; on the DbContext:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnConfiguring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DbContextOptionsBuilder&lt;/span&gt; &lt;span class="n"&gt;optionsBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;optionsBuilder&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSeeding&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Team&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Engineering"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChanges&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAsyncSeeding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Team&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Team&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Engineering"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Triggered automatically by EnsureCreated / Migrate, or manually via context.Database.EnsureCreated()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF Core's older &lt;code&gt;HasData&lt;/code&gt; API is now classified as "model managed data"; Microsoft recommends &lt;code&gt;UseSeeding&lt;/code&gt; for general-purpose seeding because &lt;code&gt;HasData&lt;/code&gt; runs as part of migrations and couples schema changes with row inserts.&lt;/p&gt;

&lt;p&gt;ORM seeders improve on raw SQL. Typed languages like TypeScript and C# catch errors at compile time, the seed code lives alongside your app, and the ORM wires direct relationships through its own API.&lt;/p&gt;

&lt;p&gt;The core problem survives, though. Data and relationships are still defined by hand, so a migration that adds a required column or changes a relation breaks the seeder &lt;a href="https://seedfa.st/blog/seed-file-maintenance" rel="noopener noreferrer"&gt;the same way a seed.sql breaks&lt;/a&gt;, only in TypeScript or PHP or C#. You're also locked to one ORM, since a Prisma seed can't be reused if half your team runs Drizzle, which is why teams weighing a move look at how &lt;a href="https://seedfa.st/compare/test-data-tool-pricing" rel="noopener noreferrer"&gt;Seedfast's flat-rate pricing compares to metered or quote-gated tools&lt;/a&gt;. For a side-by-side of 7 database seeder tools on FK resolution and schema drift, see &lt;a href="https://seedfa.st/blog/database-seeder" rel="noopener noreferrer"&gt;the database seeder tool comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Seeding Stops Being Simple
&lt;/h2&gt;

&lt;p&gt;Most teams hit a wall around 15-20 tables, sometimes at the dependency chain, sometimes at circular references, and usually at all of them in the same week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dependency chain gets deep.&lt;/strong&gt; To insert an &lt;code&gt;order_item&lt;/code&gt; you need an &lt;code&gt;order&lt;/code&gt;, a &lt;code&gt;product&lt;/code&gt;, and a &lt;code&gt;price&lt;/code&gt;; the &lt;code&gt;order&lt;/code&gt; needs a &lt;code&gt;user&lt;/code&gt; and a &lt;code&gt;shipping_address&lt;/code&gt;, and the &lt;code&gt;product&lt;/code&gt; needs a &lt;code&gt;category&lt;/code&gt; and a &lt;code&gt;vendor&lt;/code&gt;. That's three or four levels of dependency for a single line item, and the seed code becomes more about wiring IDs together than about the data itself.&lt;/p&gt;

&lt;p&gt;Circular references show up next, and they're worse. A &lt;code&gt;user&lt;/code&gt; carries a &lt;code&gt;manager_id&lt;/code&gt; that points at another &lt;code&gt;user&lt;/code&gt;, a &lt;code&gt;category&lt;/code&gt; has a &lt;code&gt;parent_category_id&lt;/code&gt;, and an &lt;code&gt;employee&lt;/code&gt; belongs to a &lt;code&gt;department&lt;/code&gt; whose own &lt;code&gt;head_employee_id&lt;/code&gt; points back at an employee. Neither side can be inserted first, so a straight top-to-bottom insert fails outright, and getting it right by hand turns into fiddly, error-prone work.&lt;/p&gt;

&lt;p&gt;Then migrations keep breaking the seeds. Every &lt;code&gt;NOT NULL&lt;/code&gt; column, every new FK constraint, every renamed table sends someone back to the file, and on a codebase with weekly migrations &lt;a href="https://seedfa.st/blog/seed-file-maintenance" rel="noopener noreferrer"&gt;seed maintenance becomes a recurring tax&lt;/a&gt; nobody budgets for, until the file stops working and a known-good dump gets passed around instead.&lt;/p&gt;

&lt;p&gt;By now most teams either live with broken seeds, copy production (which &lt;a href="https://seedfa.st/blog/staging-without-prod-data" rel="noopener noreferrer"&gt;can carry compliance considerations&lt;/a&gt;), or start comparing the &lt;a href="https://seedfa.st/blog/best-postgres-test-data-generator" rel="noopener noreferrer"&gt;best Postgres test data generators&lt;/a&gt; and &lt;a href="https://seedfa.st/blog/best-ai-test-data-generator" rel="noopener noreferrer"&gt;best AI test data generators&lt;/a&gt; — or, when the data is specifically for a sales demo, a trial sandbox, or an investor walkthrough rather than a test run, a &lt;a href="https://seedfa.st/blog/demo-data-generator" rel="noopener noreferrer"&gt;demo data generator&lt;/a&gt;. Some arrive after a previously adopted generator was orphaned, whether &lt;a href="https://seedfa.st/compare/snaplet-seed-alternative" rel="noopener noreferrer"&gt;Snaplet Seed&lt;/a&gt; (shut down 2024, now stalled) or &lt;a href="https://seedfa.st/compare/neosync-alternative" rel="noopener noreferrer"&gt;Neosync&lt;/a&gt; (archived 2025), weighing the same options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Schemas, by the Numbers
&lt;/h2&gt;

&lt;p&gt;The 15-20 table wall isn't folklore. Seed-run metadata from Seedfast, aggregated in July 2026, puts numbers on where real projects actually sit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The median schema seeded through Seedfast has about 14 tables, and one schema in ten brings 25 or more.&lt;/strong&gt; That is squarely the territory where hand-wired dependency chains stop being worth maintaining.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A typical seed run finishes in a few minutes&lt;/strong&gt; , measured over the whole run from start to completion.&lt;/li&gt;
&lt;li&gt;Most runs ask for fixture-sized data rather than bulk loads, with typical volumes in the hundreds of rows per run and the occasional &lt;a href="https://seedfa.st/blog/load-testing-data" rel="noopener noreferrer"&gt;load-test-scale&lt;/a&gt; exception reaching into the thousands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The medians above cover completed seed runs from November 2025 through July 2026, are computed from run metadata alone rather than anything inside customer databases, and shift by only a table or two whether our own internal test runs are included or excluded.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Schema Instead of Writing the Data
&lt;/h2&gt;

&lt;p&gt;Every failure mode above shares one shape. A human wrote the data, the schema moved, and the data went stale. The fix is to invert who reads whom, so the generator reads the schema and produces rows that satisfy the constraints it finds.&lt;/p&gt;

&lt;p&gt;This is how &lt;a href="https://seedfa.st/docs/seed-your-database" rel="noopener noreferrer"&gt;Seedfast&lt;/a&gt; works. You connect it to your schema, with no production data or PII leaving your database, and it generates a full dataset from the schema itself — the rows come out connected and valid, with no relationships to wire up by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Point Seedfast at your database (connection string or DATABASE_URL)&lt;/span&gt;
seedfast connect

&lt;span class="c"&gt;# Generate data from the current schema&lt;/span&gt;
seedfast seed &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"e-commerce store with electronics products"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two commands above are the whole setup once you &lt;a href="https://seedfa.st/docs/installation" rel="noopener noreferrer"&gt;install the CLI&lt;/a&gt;, and the same call happens automatically when &lt;a href="https://seedfa.st/blog/claude-code-mcp-database-seeding" rel="noopener noreferrer"&gt;Claude Code drives the seeding through Seedfast's MCP server&lt;/a&gt; instead of a terminal prompt. Seedfast reads your PostgreSQL schema and generates a dataset that respects your declared constraints and foreign keys. Tell it &lt;code&gt;--scope "e-commerce store with electronics"&lt;/code&gt; and you get reviews that reference real products and users whose orders make sense together, because the flag describes the domain in words and the schema supplies the structure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbon3j2ldbfki469rxam.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbon3j2ldbfki469rxam.jpg" alt="Seedfast performing database seeding on a Postgres schema: a plain-English scope approved for 16 tables and 1,530 connected records, with live per-table row counts as users, products, orders, comments, and order_items fill in with connected records." width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because Seedfast reads the database rather than your ORM schema, it works with Prisma, Drizzle, TypeORM, Laravel Eloquent, EF Core, and others, whichever one defined your &lt;strong&gt;PostgreSQL&lt;/strong&gt; schema. When a migration changes the schema, the next &lt;code&gt;seedfast seed&lt;/code&gt; picks up the new structure, usually with no seed file to update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Quick local dev setup&lt;/span&gt;
seedfast seed &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"small team with a few projects"&lt;/span&gt;

&lt;span class="c"&gt;# Integration test data&lt;/span&gt;
seedfast seed &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"3 users with 2 orders each"&lt;/span&gt;

&lt;span class="c"&gt;# Staging demo&lt;/span&gt;
seedfast seed &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"realistic e-commerce with 500 products and reviews"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production reference data like roles and feature flags, a version-controlled SQL file is still the right tool, and the two pair well, since you run your reference seeds first and let Seedfast fill in around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Seeding in CI/CD
&lt;/h2&gt;

&lt;p&gt;In a pipeline, database seeding needs to be automated, fast, and isolated. No shared databases between parallel test runs, no manual "run this script first" steps, no &lt;a href="https://seedfa.st/blog/e2e-test-fixtures" rel="noopener noreferrer"&gt;fixtures drifting from the schema&lt;/a&gt;. Python suites get the same guarantee from &lt;a href="https://seedfa.st/blog/pytest-database-fixtures" rel="noopener noreferrer"&gt;pytest database fixtures&lt;/a&gt;, scope and transactional rollback standing in for a reseed between tests. For a full pipeline setup guide, see the &lt;a href="https://seedfa.st/docs/cicd-database-seeding" rel="noopener noreferrer"&gt;CI/CD database seeding docs&lt;/a&gt;, and for the service-container and health-check mechanics underneath it, see &lt;a href="https://seedfa.st/blog/github-actions-seed-postgres-database" rel="noopener noreferrer"&gt;Seed a Postgres Test Database in GitHub Actions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The ideal is a single command after migrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# GitHub Actions&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run migrations&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx prisma migrate deploy&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Seed test database&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SEEDFAST_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SEEDFAST_API_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;seedfast seed --scope "minimal checkout flow"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ORM seeders work here too, whether &lt;code&gt;npx prisma db seed&lt;/code&gt;, &lt;code&gt;php artisan db:seed&lt;/code&gt;, or EF Core seeding via &lt;code&gt;dotnet ef database update&lt;/code&gt;, as long as the seeder script is maintained and the CI environment matches dev. A schema-aware generator needs no maintained script, so the same command works whether the last migration added one column or ten tables.&lt;/p&gt;

&lt;p&gt;Many teams share one PostgreSQL database where each service owns its tables. There, Seedfast fills every service's tables in a single command, with none of the "seed users first, then orders, then payments" choreography. If your services use &lt;a href="https://seedfa.st/blog/microservice-seeding" rel="noopener noreferrer"&gt;separate databases&lt;/a&gt;, that's a harder problem with its own strategies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Seeding Best Practices
&lt;/h2&gt;

&lt;p&gt;The first rule follows straight from the two-kinds distinction above. Production reference data belongs in migrations or a dedicated reference seed that runs in every environment, while test data belongs in a separate step confined to dev, test, and staging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make seeds idempotent.&lt;/strong&gt; A seed that fails on its second run is a seed that breaks CI after a retry, so reach for &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;, upsert patterns, or a clear-and-reseed strategy.&lt;/p&gt;

&lt;p&gt;Volume should match the use case, too. &lt;a href="https://seedfa.st/blog/small-data-big-lies" rel="noopener noreferrer"&gt;Tiny datasets hide bugs&lt;/a&gt; like broken pagination, missing indexes, and queries that only crawl at scale, so while local dev can stay small, integration tests should carry enough data to hit edge cases and &lt;a href="https://seedfa.st/blog/load-testing-data" rel="noopener noreferrer"&gt;load tests need production-scale volumes&lt;/a&gt;. The SQL patterns for producing that volume by hand, &lt;code&gt;generate_series()&lt;/code&gt; and bulk &lt;code&gt;\copy&lt;/code&gt; among them, live in the &lt;a href="https://seedfa.st/blog/test-data-postgresql" rel="noopener noreferrer"&gt;PostgreSQL test data cookbook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Copying production is the shortcut to avoid. A &lt;code&gt;pg_dump&lt;/code&gt; gives realistic data but drops PII into every environment that receives it, and quality masking is harder than it sounds, since you must catalog every PII-bearing column (JSON fields, free-text notes, audit logs) and keep that catalog current as the schema evolves. &lt;a href="https://seedfa.st/blog/staging-without-prod-data" rel="noopener noreferrer"&gt;Miss one and PII leaks through&lt;/a&gt;. Generating synthetic data from the schema cuts this risk substantially, since production rows never leave production. (The generator still reads schema metadata like table and column names, so regulated teams should review what those names reveal.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate, don't document.&lt;/strong&gt; If your onboarding doc lists four commands to run in a specific order for a working database, that's a seed script waiting to be written, and the target is one command with no decisions left to whoever runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is database seeding?
&lt;/h3&gt;

&lt;p&gt;Database seeding populates a database with an initial dataset, either reference data for production or test data for non-production. Which method fits, from a SQL file to an ORM seeder to a schema-aware generator, depends mostly on how often your schema changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is db seeding?
&lt;/h3&gt;

&lt;p&gt;Db seeding is just shorthand for the same act. The abbreviation comes from tooling, since seed commands ship as &lt;code&gt;db:seed&lt;/code&gt; in Laravel and Rails and &lt;code&gt;prisma db seed&lt;/code&gt; in Prisma; the terms are otherwise identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it mean to seed a database?
&lt;/h3&gt;

&lt;p&gt;You run a script against a freshly migrated schema and let its &lt;code&gt;INSERT&lt;/code&gt;s execute, be it a &lt;code&gt;seed.sql&lt;/code&gt;, an ORM seeder, or a generator reading the live schema. The point is turning an empty-but-valid database into one the app can run against.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is seeding in database?
&lt;/h3&gt;

&lt;p&gt;Seeding is the lifecycle step between migration and application start. Migrations build the structure and seeding supplies the contents, which is the difference between a database that's correct on paper and one a developer can click through or demo.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a seed script?
&lt;/h3&gt;

&lt;p&gt;A seed script is the artifact that does the seeding, version-controlled and re-run when you need data, be it a &lt;code&gt;.sql&lt;/code&gt; file, an ORM seeder class, or a one-command generator. A durable one is idempotent, so a second run changes nothing instead of erroring on duplicate keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does seed data mean?
&lt;/h3&gt;

&lt;p&gt;Seed data splits by where each kind may travel. Reference data (roles, country codes) ships everywhere including production, while development and test data (fake users, fixtures) must never reach it, which is exactly why the two belong in separate steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between migration and seeding?
&lt;/h3&gt;

&lt;p&gt;Both run before the app, hence the confusion, but they do opposite jobs. A migration changes structure and is versioned to run once; a seed adds rows and stays idempotent. Keep them apart, since a migration carrying test rows eventually breaks a deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do we seed a database?
&lt;/h3&gt;

&lt;p&gt;Because a valid but empty database is useless, all blank dashboards and tests that fail before they start. Seeding makes the schema usable, and a shared seed gives every developer and CI run the same starting point instead of whatever the last job left.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should you seed a production database?
&lt;/h3&gt;

&lt;p&gt;Only with reference data, the roles and default settings the app's contract depends on. Development and test data must never land there, so keep two separate steps, a small reference seed that runs everywhere including production and a larger dev/test seed confined to non-production.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is idempotent seeding?
&lt;/h3&gt;

&lt;p&gt;Idempotent seeding produces the same end state however many times it runs. The second run becomes a no-op instead of a duplicate-key crash, via &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; in PostgreSQL, &lt;code&gt;find_or_create_by!&lt;/code&gt; in Rails, or &lt;code&gt;upsert&lt;/code&gt; in Prisma, the minimum bar for CI where retries are routine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a seed.sql file?
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;seed.sql&lt;/code&gt; is the lowest-common-denominator seed, a plain SQL file of &lt;code&gt;INSERT&lt;/code&gt;s you run with &lt;code&gt;psql -f seed.sql&lt;/code&gt; after migrations build the tables. It has no dependencies and stays deterministic, but it's a snapshot of one schema version, so the next breaking migration invalidates it until someone edits it by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I seed a database in CI/CD?
&lt;/h3&gt;

&lt;p&gt;Add a seed step right after migrations, one command with no manual steps and no shared state across parallel runs. ORM seeders work if someone maintains the script, while &lt;code&gt;seedfast seed&lt;/code&gt; skips that upkeep by reading the current schema on every run. Host mechanics vary, so on Neon &lt;a href="https://seedfa.st/blog/seed-neon-database" rel="noopener noreferrer"&gt;seed the parent once and every branch inherits the data&lt;/a&gt;, while on &lt;a href="https://seedfa.st/blog/supabase-db-seed" rel="noopener noreferrer"&gt;Supabase, preview branches re-apply &lt;code&gt;seed.sql&lt;/code&gt; on creation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Seedfast work with my ORM?
&lt;/h3&gt;

&lt;p&gt;The ORM barely matters, because Seedfast talks to the database rather than to your ORM. Prisma, Drizzle, TypeORM, Sequelize, Laravel Eloquent, EF Core, Django ORM, Rails ActiveRecord, SQLAlchemy, or raw SQL all work the same way, provided you're running PostgreSQL. Run your migrations first, then &lt;code&gt;seedfast seed&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I use a seed file vs a generator?
&lt;/h3&gt;

&lt;p&gt;Use a version-controlled seed file for small, stable, deterministic data like production reference rows; use a generator for data that has to span many tables, respect foreign keys, and survive schema changes without hand edits. For a category-level breakdown of DIY scripts, web generators, enterprise anonymization, and schema-aware generators, see &lt;a href="https://seedfa.st/blog/data-seeding-tools" rel="noopener noreferrer"&gt;the data seeding tools comparison&lt;/a&gt;; for a methods-level comparison, see &lt;a href="https://seedfa.st/blog/test-data-generation" rel="noopener noreferrer"&gt;Test Data Generation: 7 Methods Compared&lt;/a&gt;. On Postgres, the &lt;a href="https://seedfa.st/blog/best-postgres-test-data-generator" rel="noopener noreferrer"&gt;best Postgres test data generator comparison&lt;/a&gt; ranks options on schema-awareness, FK validity, and CI fit, and you can &lt;a href="https://seedfa.st/compare" rel="noopener noreferrer"&gt;see how the tools stack up side by side&lt;/a&gt; on the comparison hub.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does database seeding fit into test data management?
&lt;/h3&gt;

&lt;p&gt;Database seeding is one mechanism inside test data management, the act of getting initial data into a database. Test data management is the wider discipline around it, deciding where data comes from, how it stays valid as the schema moves, and who can see it. The &lt;a href="https://seedfa.st/blog/test-data-management" rel="noopener noreferrer"&gt;test data management guide&lt;/a&gt; covers the four-tier breakdown and the in-house framework that wraps this seeding work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed your database in one command
&lt;/h2&gt;

&lt;p&gt;The methods on this page fail in a predictable order. Seed files break first, ORM seeders break the same way a little later, and factory code holds out longest while costing the most to keep alive. What they all share is a human encoding the schema by hand, so a generator that reads the schema itself on every run is the one version of seeding a migration cannot invalidate. Building &lt;a href="https://seedfa.st/" rel="noopener noreferrer"&gt;Seedfast&lt;/a&gt; around that idea is why, for PostgreSQL and whichever ORM sits on top, a migration that would break a seed file just produces the next dataset instead. The &lt;a href="https://seedfa.st/pricing" rel="noopener noreferrer"&gt;30-day free trial&lt;/a&gt; runs on your own schema without a card, if you'd rather watch that happen than read about it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://seedfa.st/blog/database-seeding" rel="noopener noreferrer"&gt;seedfa.st&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>testing</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
