Markdown Table to Schema
This markdown table to schema converter takes a table you have already written — in a README, a pull request description, a notebook cell, a Confluence page or a design document — and turns every row into a typed field definition. Paste the table into the box on this page, and 300 milliseconds after you stop typing the output pane fills with a JSON Schema draft-07 document. Four tabs cover the targets most people need: JSON Schema, a TypeScript interface, a SQL CREATE TABLE statement and a GraphQL type. The copy button takes whichever tab is on screen, so the definition travels straight into your code.
Everything runs in your browser. There is no upload, no account and no server round trip, which matters when the table describes an internal API. The conversion is re-run after a short debounce rather than on every keystroke, so a table pasted in one go is parsed once, not once per character.
Markdown Table
Supports standard Markdown table format
Generated Schema
What the Markdown Table to Schema Converter Reads and Writes
The input is an ordinary Markdown table of the kind GitHub, Jupyter, Confluence and Slack already render. This markdown table converter reads it with a handful of simple rules, and every one of them is visible in the output, so nothing about the result should surprise you:
- The first line is the header row. Each header cell is trimmed and classified by keyword: a header containing name, field or property becomes the name column, type becomes the type column, and description, desc or note becomes the description column.
- The alignment row is skipped. If the second line contains
---, it is treated as the separator row and ignored, so it never turns into a field of its own. - Edge pipes are optional. A leading pipe, a trailing pipe or both may be present;
| id | int |andid | intare read the same way. - There is a positional fallback. If no header carries a role keyword, the first cell of a row is taken as the name, the second as the type and the third as the description.
- A row without a name is dropped, and a field without a type word defaults to
string. - Repeated names are suffixed in the JSON Schema tab. A second field called
idbecomesid_2, a third becomesid_3, so no row silently overwrites an earlier one. The TypeScript, SQL and GraphQL tabs print each name exactly as your table spells it.
Most visitors arrive with one of two requests: markdown table to JSON Schema for a contract or a client, or a quick CREATE TABLE statement to seed a migration draft. The markdown to JSON schema step is the same in both cases — parse the table once, then render the parsed rows for the target you picked.
Three situations bring people to a tool like this one, and it is fair to say what it is and what it is not in each of them:
- API documentation. A table of request or response fields becomes a JSON Schema you can paste into a spec, or a GraphQL type that lines up with the docs page it came from.
- Database migration drafts. Getting SQL from Markdown table definitions you have already reviewed is faster than typing DDL by hand, and the descriptions survive as column comments.
- README tables to typed code. The configuration table in a project README becomes a TypeScript interface with the descriptions carried over as JSDoc comments.
- What it is not. It is a shape converter, not a validator: it does not check types against data, does not infer a column type beyond the keyword maps on this page, and its SQL is a starting point to review rather than a migration to run.
Formulas and Type Maps Behind the Conversion
There is no arithmetic in this tool — the only quantities are text and the maps below. Write \( h \) for a header cell, \( v_i \) for the \( i \)-th cell of a data row, and \( t \) for the type word that row supplies.
| Symbol | Meaning | Rule and relation |
|---|---|---|
| L | the non-empty input lines | taken in order; fewer than two of them means an empty result |
| h | a header cell | from line one, split on the pipe character and trimmed |
| ρ(h) | the role of a header | name / type / description / other, decided by keyword |
| v | a cell of a data row | split and trimmed exactly like a header |
| t | the type word of a field | empty becomes string |
| τ(t) | the target type | one of the four maps below, applied to t |
Parsing first classifies each header. A header that contains the word name, field or property is the name column; the word type marks the type column; description, desc or note marks the description column; anything else is left over and ignored when a name column exists:
\[ \rho(h) = \mathrm{name} \ \text{if } h \text{ contains name, field or property}; \quad \mathrm{type} \ \text{if } h \text{ contains type}; \quad \mathrm{description} \ \text{if } h \text{ contains description, desc or note}; \quad \mathrm{other} \ \text{otherwise} \]
Each data row is then assembled from its cells — by role when the table has a name column, and by position when it does not:
\[ r = \bigl(\rho(h_i) \mapsto v_i\bigr) \ \text{when a name column exists}, \qquad r = (v_1, v_2, v_3) \mapsto (\mathrm{name}, \mathrm{type}, \mathrm{description}) \ \text{otherwise}, \qquad \mathrm{type} = \mathrm{string} \ \text{when empty} \]
A row only survives this step if its name cell is not empty, so blank spacer rows and separator fragments disappear instead of becoming nameless fields. The surviving type words are compared as substrings, in a fixed order, which is why integer and bigint land in the same bucket as int — and why a word like point is read as INT in SQL, because point contains int. Conventional type names age better than clever ones. Each target applies its own chain of substring tests, and the first test that matches wins:
\[ \tau_{J}(t) = \mathrm{number} \ \text{for int, number, float or double}; \quad \mathrm{boolean} \ \text{for bool}; \quad \mathrm{string} \ \text{for date or time}; \quad \mathrm{array} \ \text{for array or list}; \quad \mathrm{string} \ \text{otherwise} \]
\[ \tau_{S}(t) = \mathrm{INT} \ \text{for int}; \quad \mathrm{DECIMAL} \ \text{for float, double or number}; \quad \mathrm{BOOLEAN} \ \text{for bool}; \quad \mathrm{TIMESTAMP} \ \text{for date or time}; \quad \mathrm{VARCHAR(255)} \ \text{otherwise} \]
where \( \tau_{J} \) is the JSON Schema map and \( \tau_{S} \) is the SQL map; the TypeScript map differs from \( \tau_{J} \) in one case only — a type word containing date becomes Date instead of string — and the GraphQL map matches int to Int, the numeric group to Float, bool to Boolean, anything containing id to ID, and everything else to String. Within the JSON Schema tab, a name that has already been used is renamed before it is written:
\[ \kappa_k = \mathrm{name} \ \text{when } k = 1, \qquad \kappa_k = \mathrm{name}\_{k} \ \text{when } k \ge 2 \]
Laid out side by side, the four maps read as one table. The second column is what the JSON Schema tab prints, then TypeScript, then SQL, then GraphQL:
| Type word in your table | JSON Schema | TypeScript | SQL | GraphQL |
|---|---|---|---|---|
| int (also integer, bigint) | number | number | INT | Int |
| number, float, double | number | number | DECIMAL | Float |
| bool (also boolean) | boolean | boolean | BOOLEAN | Boolean |
| date (also datetime) | string | Date | TIMESTAMP | String |
| time, timestamp | string | string | TIMESTAMP | String |
| array, list | array | string | VARCHAR(255) | String |
| text, string (the fallback) | string | string | VARCHAR(255) | String |
| id (also uuid) | string | string | VARCHAR(255) | ID |
Precision and boundaries. Nothing here is rounded to a fixed number of decimals, because there is no numeric result to round: every field name, type word and description in the output is copied character for character from your table, and the type names come from the fixed maps above. The input boundaries are just as plain — the box takes text, not numbers, so no field has to be greater than zero and there is no range to trip over. An empty box clears the output; fewer than two non-empty lines produce an empty result; there is no inference from data and no guessing at a type the table does not state.
How to Use the Converter
- Paste your table into the input box: the header row first, then the alignment row if your table has one, then one line per field.
- Pause for a moment. Conversion runs 300 milliseconds after the last change, so the output waits until the paste has settled instead of flickering through half-finished tables.
- Read the default tab, JSON Schema: it carries the draft-07
$schemaline,"type": "object"and apropertiesentry per field, with the descriptions preserved. - Switch tabs when the target changes. The TypeScript tab shows an interface with JSDoc comments, the SQL tab a CREATE TABLE statement with column comments, and the GraphQL tab a type with triple-quoted descriptions.
- Press Copy to put the visible tab on the clipboard — the copy button copies the active tab only, so check the tab before you paste into a file.
- Clear the box to clear the output, or paste a new table over the old one; nothing is stored between visits.
Worked Example: One Table, Four Outputs
The sample table below has a name column, a type column and a description column, and two rows. It is the table used throughout this page.
| name | type | description |
|---|---|---|
| id | int | primary key |
| email | text | user email |
The header row names the roles, so no positional fallback is needed. The second line contains --- and is skipped, and both data rows carry a name, so two fields survive: id of type int, and email of type text. The default tab renders that parsed pair as JSON Schema. Note what int becomes here — the numeric group maps to number, not to integer:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "primary key"
},
"email": {
"type": "string",
"description": "user email"
}
}
}
Both descriptions come through as description keys, and the property order follows the table: id first, then email. The same two fields are rendered for the other three targets. In the TypeScript tab the descriptions become JSDoc comments and the interface is named MyType:
interface MyType {
/** primary key */
id: number;
/** user email */
email: string;
}
In the SQL tab the numeric word becomes INT and the text word becomes VARCHAR(255), and each description is attached as a COMMENT on the column it belongs to. The table name is the fixed placeholder my_table, so rename it before the statement goes anywhere near a database:
CREATE TABLE my_table (
id INT COMMENT 'primary key',
email VARCHAR(255) COMMENT 'user email'
);
The GraphQL tab closes the loop with the same pair as Int and String, and the descriptions as triple-quoted doc strings:
type MyType {
"""
primary key
"""
id: Int
"""
user email
"""
email: String
}
Is that a lot of work for two fields? By hand it is about a minute of typing and quoting per tab; the point of the exercise is a table with fifteen fields, where the difference is a pasted table against a page of boilerplate, and where a typo in one field name is the kind of thing that only shows up later in a generated client.
Markdown Table to Schema FAQ
What can I turn a markdown table into on this page?
Four outputs from one paste: a JSON Schema draft-07 document, a TypeScript interface, a SQL CREATE TABLE statement and a GraphQL type. The JSON Schema tab is the default, so the first thing you see is the schema; the other three are one click away and are regenerated from the same parsed rows rather than from each other.
How do I get SQL from Markdown table definitions?
Paste the table and open the SQL tab: it prints a CREATE TABLE statement with one line per field, and each description becomes a COMMENT on its column. The type words go through the SQL map — int to INT, float, double or number to DECIMAL, bool to BOOLEAN, date or time to TIMESTAMP, and anything else to VARCHAR(255) — and the table name is the placeholder my_table. Treat the result as a draft: review the column types, the key constraints and the lengths before you run it.
How does the converter decide which column is the name, the type and the description?
By keyword in the header row: name, field or property marks the name column, type marks the type column, and description, desc or note marks the description column. If none of those words appears, it falls back to position — first cell name, second cell type, third cell description — so a table with headers like Column and Format still converts without renaming anything.
What happens when a column has no type, or a row has no name?
A missing type defaults to string, which maps to a JSON string, a TypeScript string, VARCHAR(255) in SQL and String in GraphQL. A row with no name value is dropped instead of being guessed at, and a table with fewer than two non-empty lines produces an empty result — the converter would rather show nothing than invent a field.
Does it validate my data or check that the types are right?
No. It is a shape converter, not a validator: it reads the type word you wrote and maps it through the tables above, and it never looks at values. If a column is labelled int while the service actually returns strings, the output will faithfully describe the column you typed rather than the data you have, so the type words in your table are the one thing worth double-checking.
What happens with duplicate field names?
In the JSON Schema tab a repeated name is suffixed instead of overwriting: the second id becomes id_2, the third id_3, so all rows survive in the properties list. The TypeScript, SQL and GraphQL tabs print each name exactly as written, which means duplicates arrive as duplicates and you get to decide how to rename them.
Does it convert units, like miles to kilometers?
No, and that is deliberate: it converts shapes, not quantities. A column declared in kilograms stays kilograms and one declared in miles stays miles, and if a single table mixes miles and kilometers the output keeps both columns as they were written — normalizing the units is a step you do in the data layer, not in a schema.
Related Tools
This page reshapes text into type definitions; the rest of the toolbox fills the gaps around it. When a schema field needs a sample value rather than a type, the random string generator produces strings of whatever length and character set you pick, which is the honest way to fill a fake payload. When the placeholder should look like a credential, the password generator builds a strong password at the length and symbol set you choose. And when the numbers that go into your table need working out first — a rate, a share, a running total — the calculator handles the arithmetic so the table you paste here already holds the right figures.

