Profile design 101: pick distributions, not values
The heuristic we use when adding a new vertical to satus. Name the distributions and the correlations first, then, and only then, sample values. An internal design doc, published.
When we design a new profile for satus, we do not start with sample rows. We start with a list of column shapes, a distribution family for each shape, and a shorter list of correlations between them. Values are the last thing we write, because values without a distribution behind them are trivia, and trivia is what generic fakers already ship. This post is the internal design doc we hand a new contributor on their first day, made public without much editing.
The doc leans on three earlier posts. The critique of value-first fakers lives in When faker is the wrong answer. The reason a fixture has to match Postgres's planner statistics, not just its constraints, is in the ecommerce profile deep-dive. The state-flag heuristic that lets us fill most timestamp columns with NULL without lying is in NULL vs NOT NULL is not the question. If you have read those three, the rest of this post is the workflow that ties them together.
The heuristic in one sentence
A profile is a list of opinions about distribution shape, ranked by how badly a wrong opinion breaks the downstream code. Everything else, including the pool of fake company names and the choice of .99 versus .49 for price endings, is downstream of that list.
We enforce this by writing the profile in a fixed order:
- Enumerate the shapes the schema contains.
- Pick a distribution family per shape.
- Name the correlations between shapes.
- Name the things you refuse to encode.
- Only then, choose the sampling pools that produce the values.
Steps 1 through 4 are where a profile earns its keep. Step 5 is where a profile looks like a faker to somebody skimming the YAML, which is why the discipline of doing 1 through 4 first has to be written down.
Step 1: enumerate the shapes
A "shape" is a distribution family that recurs across schemas, not a specific column. Every profile we have shipped is built out of the same short list. We keep the list short on purpose, because a longer list produces a knob-heavy configuration surface that new users cannot reason about.
| Shape family | Where it shows up | Wrong default that hurts |
|---|---|---|
| Popularity over a set | catalogs, providers, tenants, tags | uniform (every SKU equally popular) |
| Count per parent | items per order, appointments per patient, seats per account | normal around a small mean |
| Duration | appointment length, session length, invoice age | uniform over a range |
| Time-of-day | order placement, login, message send | uniform over 24 hours |
| Day-of-week and seasonality | orders, appointments, signups | flat calendar |
| Terminal-state mix | order status, subscription status, ticket state | equal-weight categorical |
| Monetary value | price, invoice total, contract value | uniform in cents |
| Rare-event rate | no-shows, chargebacks, refunds, churn | too rare to appear at fixture size |
| Geographic mix | country, currency, locale | US-only, or perfectly even |
Nine families, three of which (popularity, count per parent, rare-event rate) do most of the damage when they are wrong. If a new vertical has a shape that does not fit into this list, the first question is not "what distribution should we add", it is "are we sure this is a shape, or is it a value pool wearing a shape's clothes". More often than not, a proposed new shape collapses into one of the nine above once you write out how you would sample from it.
Step 2: pick a distribution family per shape
For each shape we default to one distribution family, parameterised as coarsely as we can get away with. The defaults matter because most users never override them.
| Shape family | Default family | Why this one |
|---|---|---|
| Popularity | Zipf (discrete power law) | appears across catalogs, content, and search (Wikipedia: Zipf's law; Newman's review, arXiv:cond-mat/0412004) |
| Count per parent | shifted Zipf or geometric | mode at 1, thin right tail, matches basket-size and appointments-per-patient telemetry |
| Duration | log-normal | positive, skewed, one shape parameter (Wikipedia: Log-normal) |
| Time-of-day | bimodal triangular in local time | cheap to sample, correct on the qualitative shape, no seasonal hyperparameters |
| Day-of-week | per-day multiplier over a base rate | one number per day, trivially overrideable |
| Seasonality | per-month multiplier | same reason as day-of-week; explicit, not learned |
| Terminal-state mix | weighted categorical | the only shape a categorical is honest for |
| Monetary value | log-normal in minor units | matches price distributions across every catalog we have looked at |
| Rare-event rate | Bernoulli per row, per category if needed | one probability per category, easy to tune |
| Geographic mix | weighted categorical | reflect where the traffic actually is, not where the map is symmetric |
The rule for choosing between two candidate families is: pick the one with fewer parameters that reproduces the qualitative shape. A Zipf with one exponent beats a fitted mixture of two normals every time, because the exponent survives being overridden by a user who has never opened a stats textbook. The moment a distribution needs three parameters to be useful, we have almost certainly picked the wrong family.
Step 3: name the correlations
Distributions in isolation are the easy half. The hard half is that shapes in a real system correlate with each other, and a profile that samples each column independently produces rows that are individually plausible and jointly ridiculous. A few we always ask about when scoping a new vertical:
- Time-of-day correlates with day-of-week. Weekend traffic has a different intra-day curve than weekday traffic. In the medical profile, Saturday's peak is earlier than a weekday's; in ecommerce, the evening peak is later on Friday and Saturday than mid-week.
- Seasonality correlates with rare-event rate. Holiday-season orders return at a higher rate than the annual mean; Q4-signed SaaS contracts churn at a different rate than Q2-signed ones.
- Popularity correlates with category. In ecommerce, the top decile of SKUs is not evenly spread across categories; in SaaS, the top decile of tenants is not evenly spread across plans.
- Count per parent correlates with popularity. Heavy accounts have more of everything: more users, more sessions, more invoices, more support tickets. A fixture that samples these independently produces a distribution of "how much does the top tenant use us" that is much narrower than reality.
We write the correlations down as a small block at the top of the profile, and the runner enforces them by conditioning later samples on earlier ones. The three shipped profiles, ecommerce, medical-booking, and SaaS-subscriptions, each carry a handful of these conditioners. Every one of them was added because a user reported a fixture that was individually correct and jointly wrong.
Step 4: name what you refuse to encode
Every profile ships with a short section headed "does not encode". This is not a disclaimer; it is a design decision. Anything on that list is something a user can override in their own profile file, but that we refuse to make the default because the right default depends on facts we do not have.
Recurring entries on the "does not encode" list:
- Real proper nouns. No real customer names, real product SKUs, real clinic names, real domains. Synthetic pools only. This is also our stance on PII and it does not change per vertical.
- Payment instrument data. No card numbers, no IBANs, no routing numbers, ever. If a column is named like one of these we fill zeros and warn at plan time.
- Category-specific overrides where the categories are user-defined. A
fashionsub-profile insideecommercewould be wrong for the user who asked forstreetwear; adermatologysub-profile insidemedical-bookingwould be wrong for the user who asked forteledermatology. Categories belong in user overrides. - Numbers we cannot cite. If the honest citation would be "a vendor blog post from 2019", we do not embed the number. We embed a shape and hedge the magnitude.
Writing this section forces the profile author to be explicit about the difference between "we have not shipped this yet" and "we have decided not to ship this". The two are treated very differently in issue triage.
Step 5: choose the value pools last
Only after steps 1 through 4 are on paper do we open a fake-data library and pick pools for names, cities, product descriptors, and so on. The pools are the least load-bearing choice in the profile and by far the most visible, which is the reason a value-first design keeps producing profiles that look plausible on the surface and behave wrong under load. A weighted-categorical over ["US", "GB", "DE", "FR", "JP", "other"] is a shape decision; the specific city names that go with each country are a pool decision. Getting the shape wrong makes the planner statistics wrong. Getting the pool wrong makes a screenshot look slightly less impressive.
The distinction shows up cleanly in what fails a code review. We reject a PR that sets US 0.25, GB 0.25, DE 0.25, FR 0.25 as the country weights, because that is a shape decision made without reference to any traffic we have ever seen. We do not reject a PR that swaps one plausible city list for another.
A worked micro-example: a logistics profile in one page
To make the process concrete, here is what we would sketch on a whiteboard if a user asked us to add a logistics profile tomorrow. This is not a shipped profile and the numbers are illustrative.
Shapes present: popularity (lanes, carriers), count per parent (shipments per customer), duration (transit time), time-of-day (pickup, delivery), day-of-week and seasonality (peak season), terminal-state mix (delivered, delayed, lost, returned), monetary value (shipment cost), rare-event rate (loss, damage), geographic mix (origin and destination country pairs).
Distribution families:
lane popularity Zipf, s ≈ 0.9
carrier popularity Zipf, s ≈ 0.6
shipments per customer shifted Zipf, mode 1, p99 ≈ 40
transit time (days) log-normal, median 3, p95 ≈ 12
pickup time-of-day bimodal triangular in local, peaks 09:00 / 15:00
day-of-week Mon 1.05, Tue-Thu 1.10, Fri 1.15, Sat 0.60, Sun 0.15
seasonality Nov 1.35, Dec 1.40, Jan 0.80, others 1.00
status mix in_transit 0.15, delivered 0.78, delayed 0.04,
lost 0.005, returned 0.025
shipment cost log-normal in cents, median 42.00, p95 ≈ 480.00
loss rate Bernoulli, 0.005 baseline, 3x on lanes flagged fragile
country-pair mix weighted categorical over top ~30 lanes
Correlations to enforce:
transit time conditioned on origin-destination pair
status mix conditioned on transit-time percentile (a shipment
at the 99th percentile of transit time has a higher
P(delayed) and P(lost))
seasonality pushes both order count AND transit time upward
(peak season is slower per shipment, not just busier)
Does not encode: real carrier names, real tracking-number formats, real airport codes as anything other than a neutral three-letter pool, harmonised tariff codes, customs paperwork, insurance riders.
That whiteboard sketch, plus a day of writing tests against a real customer's shipments schema, is the whole design phase. The code to sample from those distributions is standard library. The reason we can ship a plausible profile in a week rather than a quarter is that most of the work happens before any code is written.
The anti-patterns we have learned to avoid
Three anti-patterns keep showing up in profile PRs, ours and contributors', and all three are variations on skipping step 1.
The first is the "sample rows" PR. Somebody opens the file, sees a section of YAML, and adds ten literal example rows to it. Ten rows do not constitute a distribution; they constitute a very small pool. If we merged the PR the fixture would be indistinguishable from the input rows at scale, and the planner would learn statistics from a ten-row multiset. We reject these and ask for a distribution family instead.
The second is the "one big categorical" PR. Somebody replaces a Zipf or log-normal with a weighted categorical over twenty buckets, on the grounds that categoricals are easier to reason about. The categorical is easier to reason about, and it is also flat inside each bucket, and inside-bucket flatness is exactly the failure mode a Zipf was chosen to avoid.
The third is the "make it configurable" PR. Somebody adds three new knobs to a distribution because their specific use case wants them. Every knob is a knob a future user will have to reason about and get wrong, and knobs multiply. We ship the coarsest parameterisation we can defend and push per-use-case tuning into user override files, which do not need a satus release to change.
The checklist
Before merging a new profile, a contributor confirms all of the following, in this order:
- The nine (or so) shape families are enumerated, and any shape not on the standard list is justified in the PR description.
- Each shape has a distribution family with one or two parameters, defended in a comment above it.
- Correlations are named and enforced in the runner, not left to the reader to notice.
- The "does not encode" section is present and specific.
- Value pools are last in the file and short.
- The corpus-style structural claims (schema counts, table counts, and so on) in the accompanying blog post cite our own corpus or a named public source, never a vendor blog.
If any of those is missing, the profile is not ready. The runner will accept it, the tests will pass, and it will silently be another value-first faker with slightly better packaging. Nobody needs another one of those.
The shorter version
Values are the noise. Distributions are the signal. Correlations are the difference between rows that are individually plausible and a fixture that is jointly plausible. Refusals are the difference between a design decision and a backlog item.
If you want to see the process applied to real schemas, the profiles page lists the three profiles we ship today, and the quickstart walks through pointing satus at your own schema. If you are building a profile for a vertical we do not ship yet, this post is the workflow. Send us the sketch; we will read it.
References
- PostgreSQL documentation, Statistics Used by the Planner.
- Wikipedia, Zipf's law.
- M. E. J. Newman, "Power laws, Pareto distributions and Zipf's law", 2004, arXiv:cond-mat/0412004.
- Wikipedia, Log-normal distribution.
- PostgreSQL documentation, Constraints.