Generate a schema from a CSV
Drop a CSV in and get a typed schema out, in any of nine targets: TypeScript, Zod, Pydantic v2, JSON Schema 2020-12, Go, Rust serde, SQL for four dialects, Avro or Parquet. Types are read from the rows rather than guessed from the header, small-cardinality columns become enums, and any column with a blank cell becomes optional. Everything runs in this tab and no file is uploaded.
Want to know whether the file deserves a schema first? Profile it
The inference is deliberately timid
Most generators guess generously. They see digits and write an integer, they see a small set of values and write a string, and the schema you paste into your codebase fails on next month's file. This one starts from the opposite instinct: a type is only claimed when every non-blank value in the column supports it. Three rules do most of the work.
- A leading zero means string. A value like
00417,007or0800is a padded identifier, not a quantity. Type it as an integer and the padding is gone, the value no longer round-trips, and every join it took part in quietly stops matching. The check is one regular expression looking for a sign, a zero and another digit, and it runs before the numeric parse gets a say. - A decimal point means float. A column of
348.00and29.00is integral by arithmetic and decimal by intent. If a token contains a dot, the column stops being an integer column, even when every value happens to end in two zeros. The alternative is a schema that works until someone charges 348.50. - Booleans only when nothing else is present. A column is boolean when every non-blank value is one of true, false, yes, no, t, f, y, n, 0 or 1, and when the distinct set holds at most two of them once case is folded. That last clause is what stops a column of ones from being called a flag. Booleans are checked ahead of numbers, so a column of nothing but 0 and 1 comes out as a flag rather than a measure, which is the more common reading of that column in a real export.
Dates go through the same discipline. A column is a date when every non-blank value parses as one and none of them carries a time, and a datetime when every value carries a time. Mixed, and it falls back to string. Nullability is simpler still: one blank cell anywhere in the column makes the field optional everywhere, and the warnings name those columns and add, in plain words, that the fact comes from your file and not from a specification.
Worked example: one CSV, four targets, the same decisions
Here is orders.csv, six rows chosen so that every inference rule fires at least once:
order_id,customer_ref,plan,seats,amount,ordered_on,active
A-1001,00417,pro,12,348.00,2024-01-05,true
A-1002,00892,starter,3,29.00,2024-01-09,true
A-1003,01044,pro,7,203.00,2024-01-14,false
A-1004,00417,starter,1,9.00,2024-01-20,true
A-1005,02310,pro,24,696.00,2024-02-02,
A-1006,00655,starter,2,19.00,2024-02-11,false
Before any code is written, seven decisions get made. order_id is text with a longest value of 6. customer_ref is text because of the padding, longest value 5, and it is not an enum: it has five distinct values across six rows, and the enum rule wants at least three rows per distinct value, which would need fifteen. plan is an enum, two distinct values across six rows, emitted sorted as pro then starter. seats is an integer. amount is a float, on the strength of the decimal point alone. ordered_on is a date. active is a boolean and, because row five is blank, the only optional column in the file.
Pick TypeScript interface with a type name of Order and you get this:
/** Generated from a CSV by ExploreMyData. 7 columns. */
export interface Order {
order_id: string;
customer_ref: string;
plan: "pro" | "starter";
seats: number;
amount: number;
/** YYYY-MM-DD */
ordered_on: string;
active?: boolean | null;
}
The enum arrives as a union of string literals rather than a bare string, the date is typed as a string with a comment naming the format, because a CSV carries text and pretending otherwise breaks at the first parse, and the optional column gets both the question mark and the null. Now switch the target to Zod schema without touching anything else:
import { z } from "zod";
export const orderSchema = z.object({
order_id: z.string().max(6),
customer_ref: z.string().max(5),
plan: z.enum(["pro", "starter"]),
seats: z.coerce.number().int(),
amount: z.coerce.number(),
ordered_on: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"),
active: z.coerce.boolean().nullish(),
});
export type Order = z.infer<typeof orderSchema>;
Same seven decisions, expressed in the idiom Zod actually uses. The numbers are coerced, because CSV values arrive as strings and a plain z.number() would reject every row of the file it was generated from. The string columns carry the longest length seen as a max. The date is validated as text against a pattern rather than typed as z.date(), for the same reason. Now SQL CREATE TABLE on Postgres:
-- Generated from a CSV by ExploreMyData. Dialect: postgres.
CREATE TABLE "order" (
"order_id" VARCHAR(16) NOT NULL,
"customer_ref" VARCHAR(16) NOT NULL,
"plan" VARCHAR(16) NOT NULL CHECK ("plan" IN ('pro', 'starter')),
"seats" BIGINT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"ordered_on" DATE NOT NULL,
"active" BOOLEAN
);
The enum became a CHECK constraint. Every column except active is NOT NULL, which is the same nullability decision the TypeScript question mark expressed. The VARCHAR width is the longest observed value multiplied by 1.5 and rounded up, then floored at 16 and capped at 4000, so a six-character order id gets sixteen characters of room rather than exactly six. Switch the dialect to MySQL and one thing changes:
-- Generated from a CSV by ExploreMyData. Dialect: mysql.
CREATE TABLE `order` (
`order_id` VARCHAR(16) NOT NULL,
`customer_ref` VARCHAR(16) NOT NULL,
`plan` VARCHAR(16) NOT NULL,
`seats` BIGINT NOT NULL,
`amount` DOUBLE PRECISION NOT NULL,
`ordered_on` DATE NOT NULL,
`active` TINYINT(1)
);
-- MySQL alternative for the small-cardinality columns:
-- ALTER TABLE `order` MODIFY `plan` ENUM('pro', 'starter') NOT NULL;
The CHECK is gone and a commented ALTER TABLE takes its place, because MySQL has a native ENUM type and that is the idiomatic answer there. It stays a comment on purpose: a native ENUM is a decision with migration consequences, and a generator should offer it rather than impose it. Note the identifier quoting changes too, backticks on MySQL, double quotes on Postgres and SQLite, square brackets on SQL Server, and the boolean becomes TINYINT(1) on MySQL, BIT on SQL Server and INTEGER on SQLite. The table name is the type name in snake_case, which is why Order became order.
Names, and the promise not to lose one
Headers in the wild are not identifiers. Order ID, total amount and ORDER_ID all need to become something a compiler will accept, and every rename is an opportunity to silently break the mapping back to the file. The rule here is that a target may rename a field only if it can also carry the original header as the wire name.
- TypeScript, Zod, JSON Schema and SQL keep your header exactly. When it is not a valid identifier it is quoted, so
Order IDbecomes"Order ID": string;and nothing has been renamed at all. - Go gets PascalCase plus two tags.
order_idbecomes the fieldOrderIdcarrying`json:"order_id" csv:"order_id"`, and an optional column picks up,omitemptyon the json tag. Field names and types are padded to the widest in the struct, the way gofmt would leave them. - Rust gets snake_case plus serde. A header that already is snake_case is left alone with no attribute. A header that is not, such as
Order ID, produces#[serde(rename = "Order ID")]abovepub order_id: String,. Optional columns are wrapped inOption<T>. - Pydantic v2 gets snake_case plus an alias.
Order IDbecomesorder_id: str = Field(alias="Order ID"), and once any column needed an alias the model picks upmodel_config = {"populate_by_name": True}so both names work. Imports are computed from the columns present, soLiteral,Optional,dateanddatetimeappear only when something needs them. - Avro gets snake_case plus an alias array. A renamed field carries
"aliases": ["Order ID"], which is the mechanism Avro's own schema resolution uses.
The type name follows the same conversion. Whatever you type in the Type name box is run through the PascalCase converter, so subscription row, subscription-row and SUBSCRIPTION_ROW all arrive as SubscriptionRow. Leave it blank and the type is called Row. A name starting with a digit is prefixed with an f, because no target on this list accepts an identifier that opens with a number.
The nine targets, and what each is for
- TypeScript interface. A plain interface with an enum column as a union of string literals and a doc comment on date and datetime fields naming the format. No runtime cost, no dependency, no validation.
- Zod schema. The same shape with validation attached, plus the inferred TypeScript type at the bottom. Numbers and booleans are coerced so the schema parses the CSV it came from; a datetime column is validated with
z.string().datetime({ offset: true }). - Pydantic v2 model. A BaseModel with real
dateanddatetimeannotations,Literal[...]for enums,Optional[...]withdefault=Nonefor nullable columns, and aliases where the header is not a Python name. - JSON Schema 2020-12. Not draft-07. Nullable columns get a type array such as
["integer", "null"], an enum on a nullable column gets null appended to its list, dates carryformatof date or date-time, text columns carrymaxLength, and every non-nullable column is listed inrequired.additionalPropertiesis false. - Go struct. int64, float64, bool, string and
time.Timefor datetimes, with the time import added only when it is used. A nullable column of any type other than string becomes a pointer, because a Go zero value cannot tell you the cell was empty. - Rust serde struct. i64, f64, bool and String, deriving Debug, Clone, Serialize and Deserialize, with
Option<T>on the nullable columns. - SQL CREATE TABLE. Postgres, MySQL, SQLite or SQL Server, each with its own quoting, its own boolean spelling, its own timestamp type and its own text type. Enums become CHECK constraints everywhere except MySQL.
- Avro schema. A record with long, double, boolean and string, a
{ "type": "int", "logicalType": "date" }for dates andtimestamp-millisfor datetimes. Nullable fields become a union of null and the type with a default of null, in that order, which is what Avro requires. An enum whose values are all valid Avro names becomes a real enum type; if any value is not, it falls back to string rather than emitting a schema that will not load. - Parquet schema. The message definition, with
requiredoroptionalon each field,binary (STRING)for text,int32 (DATE)for dates andint64 (TIMESTAMP(MILLIS,true))for datetimes.
Read the summary, then argue with it
The generated code is the easy half. The half worth your attention is the summary above it, which lists how many columns were typed and from how many rows, how many came out optional, which columns were inferred as enums by name, and then every column with its type and a question mark where it is nullable. Four lines, and they are the thing to disagree with, because a wrong decision there is invisible in the code and obvious in the summary.
Two warnings fire on their own. If any column is blank in at least one row, the tool names those columns and says the optionality came from this file rather than from a specification, which is the sentence that saves you when the schema turns out to be stricter than reality. If the file has fewer than twenty data rows, it says the types were inferred from that few rows and points out that a column holding integers in a small sample often holds a decimal later. Both are there because a generated schema reads as authoritative and is not.
Structural warnings from the parse come through as well. Blank header cells get a placeholder name of column_N, duplicate header names get a numeric suffix so no column is lost, and both are reported rather than applied quietly. If your file needs a semicolon or tab separator, set it in the Delimiter option rather than hoping the guess lands.
Where this fits in a workflow
A schema is a claim about a file, and a claim is worth more once you have checked it. The generator is the last of three steps, not the first. Run the file through the CSV validator to find the structural problems, ragged rows, mixed line endings, encoding damage, duplicate headers. Then profile it to see the per-column completeness, the mixed value shapes, the case variants and the impossible dates. Fix what those two find, and only then generate a schema, because a schema generated from a broken file faithfully describes the breakage.
After that, the schema and a saved data contract do different jobs. The schema tells your code what shape to expect. A contract, generated from the profiler, tells you whether next month's file still matches, with a pass or a fail and the rows that caused it. If you are not sure what to check for, the data quality audit checklist walks the whole list.
Frequently Asked Questions
Why is my ID column typed as a string when it holds only digits?
Because it starts with a zero. A value like 00417 or 007 is a padded identifier, not a quantity, and typing it as an integer drops the padding and breaks every join the column was in. The rule is exactly one line: a leading zero followed by another digit means string, however numeric the rest of the column looks. A column of 417 and 892 with no padding is typed as an integer as you would expect.
Why is a column of 348.00 a float rather than an integer?
Because it was written with cents. 348.00 is an integer by value and a decimal by intent, and the generator reads the intent: any token containing a decimal point stops the column being an integer. That matters the moment a later row holds 348.50. A schema that typed the column as an integer from the first ten rows would reject the eleventh, which is the specific failure conservative inference exists to prevent.
When does a column become an enum?
When it is text, has at least two and at most twelve distinct non-blank values, and holds at least three rows for every distinct value. Both halves of that matter. Five distinct values in a five-row file is not an enum, it is five values, and a schema that says so will reject the sixth. The values are sorted before they are emitted, so the same file always produces the same enum in the same order.
What makes a field optional?
One blank cell anywhere in the column. Nullability is read from the file and nothing else: if a column is empty in a single row, it is optional in every target, and the warnings say so in as many words, naming the columns and adding that the fact comes from this file rather than from a specification. If the column is never blank it is required, which in SQL is NOT NULL and in JSON Schema means it appears in the required array.
Does it rename my columns?
Only where the target language demands it, and never without keeping the original as the wire name. Go gets PascalCase fields with json and csv struct tags carrying the exact header. Rust and Avro get snake_case, with a serde rename attribute or an Avro alias holding the header. Pydantic gets a snake_case attribute with a Field alias and populate_by_name turned on. TypeScript, Zod, JSON Schema and SQL keep your header verbatim, quoted when it is not a valid identifier.
Why does MySQL get a comment instead of a CHECK constraint?
Because MySQL has its own ENUM column type and the idiomatic answer there is not a CHECK. Postgres, SQLite and SQL Server all get a real CHECK constraint listing the allowed values inline in the CREATE TABLE. MySQL gets the same table without the constraint, plus a commented ALTER TABLE line per enum column showing the MODIFY statement that turns it into a native ENUM. It is a comment because it is a choice you should make deliberately, not one a generator should make for you.
Is the schema safe to use as it is?
Treat it as a strong first draft. It is inferred from the rows you handed over, so it describes that sample and not the specification behind it. When the file has fewer than twenty data rows the tool says so explicitly, because a column that happens to hold integers in a small sample often holds a decimal later. Read the enum lists, read the optional columns, widen anything you know can vary, then commit it.
Does the file leave my computer?
No. There is no upload endpoint on this page. JavaScript in your tab parses the CSV, walks each column, decides the types and writes the code out. Nothing is kept between visits, so a reload gives you an empty box and no history of what you generated.
Related
Get a schema you can actually keep
Free, no account, no upload. Nine targets, four SQL dialects, and types read from your rows rather than guessed.
Back to the schema generator