Skip to the lesson
CivOps AI Academy · F04Data That Looks Like Your Plant: Synthetic Data in Postgres
0%

Chapter 1 · Session 4

Why synthetic, and what realistic means

Every screen in the next weeks is built and tested before real data flows. It needs data that behaves like your plant: the right volumes, the right spread, the right rhythm of shifts, and the rare bad days. None of it may be real.

20 minNo real recordsSame schema everywhereRealistic, not random

By the end of this chapter you can

  • Explain why development and tests run on synthetic data, never real records.
  • Name what makes synthetic data realistic: volume, distribution, rhythm, rare events and mistakes.
  • Collect the handful of plant facts the generator needs.

Why not just copy the real data?

Real plant data carries people's names, customer part numbers, prices and sometimes recipes. Copied into a development database, it is exposed to everyone and everything that touches that database: builders, test runs, preview links and AI agents. Removing names is not enough. NIST's guidance on de-identifying data sets explains how records can often be traced back to people by combining the fields that remain [1]. The safe rule is simple: development and tests hold synthetic data only, and real data stays in production.

Where synthetic data belongsThree environments: development and tests or previews hold only synthetic data; production holds real plant data. Code moves from left to right; real data never moves left. Developmentsynthetic data onlyagents and builders work hereTests and previewssynthetic data onlyrebuilt on every runProductionreal plant datareal people, real decisionscode moves rightcode moves rightreal data never moves leftSame schema everywhere; only the rows differ.
Where synthetic data belongs. The schema is the same everywhere; only the rows differ. Code moves towards production. Real data never moves back.

Synthetic data also lets you start now. Session 4 is in week 2; real data from the floor arrives much later, once the screens, access rules and IT approval are in place. With realistic synthetic data, every screen from Session 8 on is built against data that looks like the plant from the first day.

Realistic is not random

Random numbers between 0 and 100 make every screen look fine and test nothing. Realistic data behaves like the plant in five ways:

PropertyWhat it meansStamping plant
VolumeAs many rows as a real quarter would makeAbout 1,560 lots and 6,240 inspections in 13 weeks
DistributionValues spread the way the process spreadsFlange height around 12.00 mm, most within ±0.06 mm
RhythmShifts, weekends, breaks, die changesTwo shifts on weekdays, nothing at night or on weekends
Rare eventsThe bad days happen, at about the real rateAbout 1 first piece in 20 fails; a few holds a week
MistakesLate, missing and corrected entriesSome results typed in late; a few corrections
A day of inspectionsInspections per hour over one day: none at night, about six an hour on two shifts, with peaks of nine in the hours with a die change. 00:0003:0006:0009:0012:0015:0018:0021:0024:00Day shift 06:00 to 14:30Second shift 14:30 to 23:00Six presses inspected hourly; amber hours include a die change and its first-piece checks. Nothing runs at night.
The rhythm of a day. Two shifts on weekdays, about six inspections an hour across six presses, more in the hours with a die change, and nothing at night. Generated data must keep this rhythm, or screens that group by shift or hour are never really tested.

Measurements from a stable process usually follow a normal distribution: most values close to the average, fewer further out, in a bell shape [2]. Statistical process control charts are built on exactly that pattern, so data generated this way exercises the charts and limits you will build later [3].

Generated measurements against the limitsA bell-shaped histogram of 400 generated flange heights centred on 12.00 mm, with the lower limit at 11.90 mm and the upper limit at 12.10 mm marked. lower limit 11.90upper limit 12.10nominal 12.0011.9011.9512.0012.0512.10400 generated values, mean 12.00 mm, standard deviation 0.03 mm
Generated hourly results, drawn to scale. 400 values from a normal distribution with mean 12.00 mm and standard deviation 0.03 mm. Almost all of them fall inside the limits.

Five facts from the floor

You do not need the real data to make realistic data. You need a few facts, which the person who decides (from Session 2) can give you in ten minutes:

  1. How many presses, lines or cells, and which shifts they run.
  2. How many lots (or orders, or batches) each makes in a shift.
  3. For the key measurement: the target, the limits, and how much it usually varies.
  4. How often the bad event happens: failures, holds, breakdowns, per week.
  5. What usually goes wrong with the records: late entries, gaps, typing errors.

Knowledge check

Why not copy production data into the development database with the names removed?

Knowledge check

Which generated data would best test a hold screen?

References

  1. NIST SP 800-188: De-Identifying Government Datasets. https://csrc.nist.gov/pubs/sp/800/188/final
  2. NIST/SEMATECH e-Handbook of Statistical Methods. https://www.itl.nist.gov/div898/handbook/
  3. ASQ: Control chart. https://asq.org/quality-resources/control-chart

Chapter 2 · One seed file

Generate it in Postgres

No extra tool or paid service is needed. Postgres can generate a quarter of plant data in a second, from one file in your repository, and produce exactly the same rows every time.

25 mingenerate_seriesrandom_normalsetseed: same rows every run

By the end of this chapter you can

  • Use generate_series to make rows for every day, shift, press and lot.
  • Use random_normal and setseed for realistic, repeatable values.
  • Keep the generator as supabase/seed.sql in the repository, and run it on a development project.

Three Postgres tools

FunctionWhat it doesUsed for
generate_series(a, b)Returns one row for each number from a to b [1]Days, shifts, presses, lots, hours
random_normal(mean, sd)A random value from a normal distribution (Postgres 16 and later) [2]Measurements that spread like the process
setseed(x)Fixes the random sequence for the session [2]The same rows every time the file runs

Combining several generate_series calls in one query makes every combination: 65 working days × 2 shifts × 6 presses × 2 lots gives 1,560 lots in a single statement. random() picks a part for each lot, and random_normal gives each measurement its spread.

The generatorPlant parameters feed a seed script using setseed and generate_series, which fills the tables; checks compare the result with the plant before screens and tests are built on it. Parametersfrom the plantseed.sqlone SQL fileTableslots, inspections, holdsCheckscounts and shapesScreens and testsbuilt on it6 presses, 2 shiftsmean 12.00 mm, sd 0.03first-piece sd 0.05a check fails: change the parameters in seed.sql, not the rows
The generator. A few facts from the plant go in, rows come out, and checks (chapter 3) decide whether the result looks like the plant. If it does not, change the parameters and run the file again.

The seed file

Supabase's convention is a file called supabase/seed.sql, which the Supabase CLI loads after the migrations whenever a local database is reset [4]. This is the stamping plant's seed file, written for the schema from Session 3. It expects the two test users from Session 2B, operator.test@example.com and lead.test@example.com, to exist.

supabase/seed.sql
-- supabase/seed.sql: the stamping plant's synthetic data. The same rows on every run.
select setseed(0.42);

insert into public.sites (code, name) values ('demo-plant', 'Demo Plant (synthetic)');
insert into public.equipment (site_id, code, name, kind)
  select 1, 'press-' || lpad(n::text, 2, '0'), 'Press ' || n, 'press' from generate_series(1, 6) as n;
insert into public.parts (part_number, name)
  values ('DEMO-1001', 'Bracket'), ('DEMO-1002', 'Flange'), ('DEMO-1003', 'Hinge plate'), ('DEMO-1004', 'Mount');
insert into public.characteristics (part_id, name, lower_mm, upper_mm)
  select id, 'flange height', 11.900, 12.100 from public.parts;

-- 13 weeks of weekdays, 2 shifts, 6 presses, 2 lots per press per shift.
insert into public.lots (site_id, part_id, equipment_id, lot_code, started_at)
  select 1, 1 + floor(random() * 4)::int, p,
         'L-' || lpad((row_number() over (order by d, s, p, l))::text, 5, '0'),
         timestamptz '2026-07-06 06:00 America/New_York' + d * interval '1 day' + s * interval '8 hours 30 minutes' + l * interval '4 hours'
  from generate_series(0, 90) d, generate_series(0, 1) s, generate_series(1, 6) p, generate_series(0, 1) l
  where d % 7 < 5;                                   -- 6 July 2026 is a Monday: days 5 and 6 of each week are the weekend

-- A first piece (wider spread after the die change) and three hourly checks per lot; the die wears a little each hour.
insert into public.inspections (site_id, lot_id, characteristic_id, kind, value_mm, recorded_by, recorded_at)
  select l.site_id, l.id, c.id,
         case when h = 0 then 'first_piece' else 'hourly' end,
         round(random_normal(12.000 + h * 0.004, case when h = 0 then 0.05 else 0.03 end)::numeric, 3),
         (select id from auth.users where email = 'operator.test@example.com'),
         l.started_at + h * interval '1 hour' + random() * interval '10 minutes'
  from public.lots l join public.characteristics c on c.part_id = l.part_id, generate_series(0, 3) h;

-- The quality lead holds each lot at its first result outside the limits, about 12 minutes later.
insert into public.holds (site_id, lot_id, action, reason, decided_by, decided_at)
  select distinct on (i.lot_id) i.site_id, i.lot_id, 'hold',
         format('Flange height %s mm outside %s to %s', i.value_mm, c.lower_mm, c.upper_mm),
         (select id from auth.users where email = 'lead.test@example.com'),
         i.recorded_at + interval '12 minutes'
  from public.inspections i join public.characteristics c on c.id = i.characteristic_id
  where i.value_mm not between c.lower_mm and c.upper_mm
  order by i.lot_id, i.recorded_at;

Read it from the top. The reference data comes first. Then come 13 weeks of lots on weekdays and shifts only. Each lot gets a first piece with a wider spread, because set-up is never perfect, and three hourly checks that drift upward a little as the die wears. Last, a hold for every lot at its first out-of-limit result, recorded 12 minutes later. That delay is the stamping plant's target from its intent.

Die wear in the generated dataA run chart of forty hourly values that drift upward as the die wears, until one crosses the upper limit and the lot is held. upper limitlower limitnominalfirst value over the limit: holdHours since the last die change →
Die wear in generated data. Hourly values drift up as the die wears until one crosses the upper limit, which is the moment a hold should happen. A screen built on this data shows the drift before it becomes a hold.

Where to run it

The free way is a second Supabase project for development, on the free plan, which allows two projects [5]. The seed runs there, and your company's main project stays clean for the real data that comes later. Free projects pause after a week without use; open the dashboard to wake one. Apply the same migrations to both projects, so the schema is identical.

Running it again

Because inspections and holds are append-only (Session 3), the seed cannot delete and redo rows one by one: the trigger refuses. On the development project, empty the tables in one statement first. truncate does not fire the row triggers, and restart identity makes the ids start from 1 again.

Development project only: empty the tables before re-running the seed
truncate public.holds, public.inspections, public.lots, public.characteristics, public.parts, public.equipment, public.sites restart identity cascade;

Knowledge check

Why start the seed file with setseed?

Knowledge check

Where does the seed file run?

References

  1. PostgreSQL Documentation: Set Returning Functions (generate_series). https://www.postgresql.org/docs/current/functions-srf.html
  2. PostgreSQL Documentation: Mathematical Functions (random, random_normal, setseed). https://www.postgresql.org/docs/current/functions-math.html
  3. G. E. P. Box and M. E. Muller, A Note on the Generation of Random Normal Deviates, Annals of Mathematical Statistics, 1958. https://doi.org/10.1214/aoms/1177706645
  4. Supabase Docs: Seeding your database. https://supabase.com/docs/guides/local-development/seeding-your-database
  5. Supabase pricing (free plan limits). https://supabase.com/pricing

Chapter 3 · Before anyone builds on it

Check it looks like your plant

Synthetic data is only useful if it fails in the same places the plant does. Four queries and ten minutes with the person who decides tell you whether it does.

20 min4 check queries1 scorecardHomework 1

By the end of this chapter you can

  • Run counts and rates on the generated data and compare them with the plant's own answers.
  • Add the awkward cases: late entries, corrections, gaps.
  • Show the person who decides, and capture the result for Homework 1.

Four queries

Run these in the SQL editor of the development project [2]. Each one answers a question the person who decides can check from memory: how much, how often, how bad.

Supabase SQL editor: does it look like the plant?
-- 1. How many rows, and over how long?
select (select count(*) from public.lots) as lots, (select count(*) from public.inspections) as inspections,
       (select count(*) from public.holds) as holds,
       (select min(recorded_at)::date from public.inspections) as first_day, (select max(recorded_at)::date from public.inspections) as last_day;

-- 2. Inspections per shift (two shifts a working day).
select round(count(*)::numeric / count(distinct (recorded_at at time zone 'America/New_York')::date) / 2, 1) as per_shift
from public.inspections;   -- the plant's own time zone decides which day a shift belongs to

-- 3. Share of first pieces outside the limits.
select round(100.0 * avg(case when i.value_mm not between c.lower_mm and c.upper_mm then 1 else 0 end), 1) as pct_first_piece_out
from public.inspections i join public.characteristics c on c.id = i.characteristic_id
where i.kind = 'first_piece';

-- 4. Holds per week.
select round(count(*)::numeric / count(distinct date_trunc('week', decided_at)), 1) as holds_per_week from public.holds;
Does it look like the plant?Five generated figures beside the plant's own answers. Four match; results entered late do not, so late entries must be added to the generator. GeneratedThe plant saysLooks right?Inspections per shift48about 50yesFirst-piece failures4.6%3 to 5%yesHolds per week5.85 to 8yesLots per press per shift22 to 3yesResults entered late (over 1 h)0%about 5%no: add late entries
The stamping plant's scorecard. Four figures match what the quality lead expects. Late entries do not: the generator records every result on time, which no real shift does. So the generator needs late entries.

Add the awkward cases

Real records are messy, and screens must cope. Before you call the data finished, add the cases that break screens on the floor:

  • Late entries: about one result in twenty recorded an hour or more after it was measured. Add a second, later time to a random 5% of rows, or record them later.
  • Corrections: a few results corrected by a new row, with a reason, as the append-only design expects.
  • Gaps: a press down for a shift, so there are no lots at all. Screens must show 'no data', not zero.
  • Edge values: results exactly on a limit (12.100), which must count as inside.
  • Long text: a hold reason of 300 characters, to check the phone layout does not break.

Volume for later

Thirteen weeks of one site makes about 8,000 rows: enough to build and test every screen. Later sessions on performance generate a year or more the same way, by changing two numbers in the seed. Postgres handles millions of rows like these without special tuning, as long as the columns screens filter on are indexed [1].

Exercise · Generate, check and show your plant's data40 minutes

You need: A second Supabase project on the free plan, your repository with the Session 3 schema, an AI coding agent, and the five facts from your plant

Use your own intent and schema. The stamping plant's seed file in chapter 2 is a template. Never run any of this on the production project.

Outcome: supabase/seed.sql on main; a development project full of data that looks like the plant on every scorecard line; a screenshot of the seeded data, for Homework 1.

Knowledge check

The generated data shows no late entries, but the plant says about 5% of results are typed in late. What do you do?

Knowledge check

How do you know the synthetic data is realistic enough?

References

  1. PostgreSQL Documentation: Indexes. https://www.postgresql.org/docs/current/indexes.html
  2. Supabase Docs: Database (SQL editor and Table Editor). https://supabase.com/docs/guides/database/overview

Chapter 4 · 12 questions · 80% passes

Final assessment

Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.

15 min12 questions≈ 15 minutesRetake allowed

Choose one answer for each question, then submit. You will see the right answer and why for every question.

1. Which databases hold synthetic data only?
2. Why is removing names from real records not enough to make them safe for development?
3. Which is NOT one of the five properties of realistic synthetic data?
4. Measurements from a stable process usually follow which distribution?
5. What does generate_series(1, 6) return?
6. Why does the seed file start with setseed?
7. Your project runs Postgres 15. How do you generate normal values?
8. Re-running the seed, deleting rows from inspections fails. Why, and what do you do on the development project?
9. What is the free way to keep synthetic data away from production?
10. Why label synthetic rows clearly, such as 'Demo Plant (synthetic)' and DEMO part numbers?
11. The plant says about 5% of results are entered late; the generated data has none. What next?
12. Who confirms the synthetic data looks like the plant?