synthetic-csv and synthetic-json

Read a local file and write a copy with the same structure but only made-up values. Use these to produce a shareable sample of real data — for a support ticket, a bug report, or a test fixture — without sending the real records.

Both commands work entirely on local files. They take no --server and no --token, and never contact a workspace.

Syntax

Usage: curiosity-cli synthetic-csv --input <file> --output <file>

Usage: curiosity-cli synthetic-json --input <file> --output <file>

curiosity-cli synthetic-csv  --input ./customers.csv --output ./customers.synthetic.csv
curiosity-cli synthetic-json --input ./export.json   --output ./export.synthetic.json

Options

Option Description
--input Path to the input file. Alias -i. Required.
--output Path to write the synthetic file to. Alias -o. Required.

What is preserved

The output is structurally identical to the input, so anything that reads the real file also reads the synthetic one:

  • synthetic-csv keeps the header row verbatim, the same columns in the same order, and the same number of rows.
  • synthetic-json keeps every key, the nesting, and every array length. A top-level array is treated as a set of records, the same way CSV rows are.

Empty cells, empty strings and null stay empty. Only leaf values are replaced. A date stays a date in the same written format, so a column parsed as yyyy-MM-dd still parses as yyyy-MM-dd.

How values are generated

Each command makes two passes. The first samples up to 1,000 rows (or, for JSON, every leaf grouped by its property path — array elements share their parent's path, so an array of objects behaves like CSV rows sharing columns) and classifies each column/path by whichever shape its values predominantly took. The second pass rewrites every value according to that classification:

Detected kind Replacement
Boolean true/false drawn at the same ratio as the real column. The original spelling of each literal is reused.
Number Sampled from a normal distribution fitted to the column's mean and standard deviation, clipped to its observed min/max. Integer columns stay integers; decimals keep the same number of decimal places.
Date / timestamp Redrawn from the column's observed date range and re-rendered in the same format the real values used. See Dates.
Repeated text (status, category, …) Mapped to a stand-in word from a fixed vocabulary. The mapping is a hash of the value, so the same real value always becomes the same stand-in word — and equality, grouping, and joins still behave like the original.
Free text Reworded into lorem-ipsum word by word; digit runs and mixed letter/digit tokens get fresh random characters. Whitespace, punctuation, and casing shape are preserved.

A value that doesn't match its column's dominant kind is left untouched, as is a column with no non-empty samples.

The output preserves the shape of the data — row counts, value distributions, and which records share a value. It is not anonymised data: a column with a single distinct real value still has a single distinct synthetic value, free text keeps its length and punctuation, and a generated number or date can coincide with a real one, especially at the ends of the observed range. Dates are the one kind whose per-record link is deliberately broken (see Columns with gaps); for every other kind, a missing value stays missing in the same record. Review the output before sharing it outside your organisation.

Dates

A date column is detected when every sampled value in it parses under one single date format. That format is then the one the synthetic values are written back in, so the column stays readable exactly as it was:

Property Behaviour
Format The format that parsed the column is reused verbatim. 2026-01-15 cannot come back as 15/01/2026, and a trailing Z or a +02:00 offset is kept.
Granularity A column of whole days stays whole days; a column of whole seconds stays whole seconds. A date column never acquires a time-of-day.
Mean and spread The generated dates have the same average date and roughly the same standard deviation as the real ones.
Range Generated dates stay within the earliest and latest date observed in the column, so the era of the data survives.
Missing values A column with gaps keeps the same proportion of gaps. See Columns with gaps.

A date column is detected before the numeric and text kinds, so a compact yyyyMMdd column is read as dates rather than as the integers they also parse as.

How a date is generated

The column's stats are collected in whole days: the average date, and the standard deviation around it. A value is then produced by taking that average date and adding a random number of days, drawn from a normal distribution scaled by the standard deviation and bounded by the observed range. A column that carries a time of day gets one too, at the column's own granularity.

Doing the arithmetic in days from a real anchor date is what makes an impossible value unreachable: there is no draw that produces a 30th of February or a 13th month. Every output is a real calendar date by construction, not by luck.

Columns with gaps

A date field is often empty on some records, and that pattern is worth keeping without keeping which records it applied to — a synthetic row that is empty exactly where the real one was tells you that record had no date.

So the proportion is learnt and redrawn: if a fifth of the sampled values were missing, about a fifth of the output is missing, but not the same fifth. A real gap may come back filled, and a real date may come back as a gap. Missing means an empty cell in CSV, a null in JSON, and an unset value for a workspace Time field.

A column with no gaps at all never gains any.

Recognised formats are ISO-8601 (yyyy-MM-dd, with optional THH:mm:ss, fractional seconds, Z or an offset), yyyy-MM-dd HH:mm:ss, yyyy/MM/dd, dd/MM/yyyy, MM/dd/yyyy, dd.MM.yyyy, dd-MM-yyyy and the compact yyyyMMdd.

A column that mixes dates with anything else is not a date column — one non-date value disqualifies it, because a date plan can only rewrite the form it learnt and the stray value would be left as the one real entry in the output. Such a column falls through to whichever other kind fits: a column of digits becomes a number, anything else becomes text.

Compact yyyyMMdd dates

20240115 is a date to a reader and an integer to a parser, so it takes one extra test: the leading four digits have to be a plausible year — 1900 to 2100 — on top of the month and day being valid and every sampled value parsing. A column of 8-digit identifiers is very unlikely to clear all three at once.

Two consequences worth knowing:

  • A compact column outside that year window is treated as a number, not a date. A column using a sentinel like 99991231 reads as an integer, and the generated values will not be valid dates.
  • A delimited format has no year window. Nothing but a date is written 1985-03-12, so any year round-trips in every format except the compact one.

When dd/MM/yyyy and MM/dd/yyyy both parse every value, the column is read and written day-first. The output is still valid under whichever convention the source used, because it is rendered with the same format it was read with.

Examples

A CSV with a date column, an ISO timestamp column, a repeated category, and free text:

customers.csv
customer,plan,signed_on,last_seen,seats
Alice Smith,Enterprise,2024-03-15,2026-01-15T09:30:00Z,120
Bob Jones,Starter,2023-11-02,2026-02-01T14:05:12Z,5
Carol White,Enterprise,2025-06-21,2026-02-14T08:00:00Z,340
Dan Brown,Team,2022-01-09,2026-03-03T17:45:59Z,22
Eve Black,Starter,2025-09-30,2026-03-20T11:20:30Z,8
curiosity-cli synthetic-csv --input ./customers.csv --output ./customers.synthetic.csv
customers.synthetic.csv
customer,plan,signed_on,last_seen,seats
Error AliquA,Ipsa,2022-09-20,2026-01-30T09:18:32Z,5
LabOriS LaborIs,Iure,2025-09-30,2026-03-01T23:47:52Z,95
TempoRa Ex,Ipsa,2023-06-19,2026-03-17T14:17:12Z,208
ConSeqUatUr Sunt,BeatAe,2024-10-19,2026-02-06T12:57:13Z,5
IpsUm ProidEnt,Iure,2023-07-18,2026-02-28T03:26:33Z,5

Reading across the two files:

  • signed_on stays a bare yyyy-MM-dd date with no time-of-day, inside the real 2022-01-09 … 2025-09-30 span.
  • last_seen keeps its T…Z shape and second-level granularity.
  • plan keeps its grouping: both Enterprise rows became Ipsa, both Starter rows became Iure, and Team became its own word. Grouping and joins on that column still work.
  • seats stays an integer inside the real 5 … 340 range.

JSON behaves the same way, per property path — nested objects and arrays included:

orders.json
[
  { "id": 1, "created": "2024-03-15T10:00:00Z", "due": "2026-01-15", "meta": { "updated": "2025-05-02 08:30:00" } },
  { "id": 2, "created": "2024-07-22T18:41:09Z", "due": "2026-04-02", "meta": { "updated": "2025-06-11 12:00:00" } }
]
curiosity-cli synthetic-json --input ./orders.json --output ./orders.synthetic.json

Each of the three date paths is detected separately and keeps its own format: created stays ISO with a Z, due stays a bare date, and meta.updated stays yyyy-MM-dd HH:mm:ss.

Deterministic output

Each row (or top-level JSON record) seeds its random values from its position in the file, not from process state. Re-running either command on the same input always produces the same output, so a synthetic file can be regenerated on demand instead of being stored next to the real one.

Remarks

  • No workspace connection is involved — these commands read and write local files only.
  • --output is overwritten if it already exists.
  • The commands exit with code 2 if the input file doesn't exist or isn't valid CSV/JSON.

See also

  • inspect — summarise a folder's contents before ingesting it.
  • upload-folder — ingest files into a workspace.
  • Common Options — logging and exit codes, which apply here too.
© 2026 Curiosity. All rights reserved.