JSON Toolkit

JSON to SQL: INSERT Statements, Table Design, and Type Mapping Traps

Bottom line: Turning JSON into SQL INSERT hides nested-table splitting, NULL versus missing, escaping and dialect gaps. Runnable statements and comparison tables for each trap.

Intermediate~10 min readPublished 2026-10-31Updated 2026-10-31
Cover for the JSON to SQL INSERT and table design guide

Bottom line: decide what business entity one row represents before designing tables, then write explicit multi-row VALUES inside a single transaction; settle field naming and type mapping before the import instead of patching them in SQL.

JSON describes shape; SQL also describes constraints. When you move the former into the latter, every constraint you did not write down comes back later as dirty data.

1. Decide first: one table or one record per JSON row

Look at the outermost layer of the data before designing any table. Get this decision wrong and every INSERT after it has to be rewritten.

JSON structure Suggested table design Main risk
Flat array of objects [{...},{...}] One table, many rows Inconsistent keys across records leave most union columns NULL
Object wrapping an array {"data":[...],"total":100} One table, take only data Writing total into every row misplaces the field's meaning
Nested object {"user":{"name":"x"}} Parent table plus flattened columns, or parent plus related table Uncontrolled expansion, duplicated column names
Array field {"tags":["a","b"]} Child table, or a JSON/TEXT column A one-to-many relation squeezed into one cell and no longer queryable by tag
Inconsistent keys Union columns, or split tables by type "Missing key" and "value is null" collapse into the same NULL

There is only one criterion: what business entity does one row represent? If a row is an order, the line items must live in their own table. If a row is a user, tags belong in a child table or a JSON column, not in a comma-joined string.

2. From an array to INSERT: the smallest runnable template

The safest form always lists the column names explicitly and never relies on column order:

CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT UNIQUE
);

INSERT INTO users (id, name, email) VALUES
  (1, 'Ada', 'ada@example.com'),
  (2, 'Linus', NULL);

Three details matter:

  • Write every column name. When the schema later gains a column, nothing shifts into the wrong field.
  • Multi-row VALUES works in SQLite, PostgreSQL, and MySQL, and saves a large number of round trips compared with a loop of single-row inserts.
  • NULL is a keyword; do not quote it. Writing 'NULL' stores a four-character string.

If the JSON keys do not match the column names (userId versus user_id), add a mapping layer. Do not guess with COALESCE inside the SQL. Fixing field names in the data layer is much cheaper than patching them in queries.

3. Nested objects: a parent table plus a child table

Orders are the classic one-to-many case:

INSERT INTO orders (id, customer_name, total_cents) VALUES
  (1001, 'Ada', 12900);

INSERT INTO order_items (order_id, sku, quantity) VALUES
  (1001, 'A-1', 2),
  (1001, 'B-7', 1);

This corresponds to the JSON {"id":1001,"customer":"Ada","total":129.00,"items":[{"sku":"A-1","quantity":2},{"sku":"B-7","quantity":1}]}.

Key points:

  • The length of the items array determines the number of child rows; the parent table gets exactly one row.
  • Store money as integer cents (total_cents), never as FLOAT.
  • Give the child table an order_id foreign key and commit both inserts in one transaction, otherwise the parent can exist while its items are missing.
  • If the data is read-only and nobody filters by line item, a JSON column is acceptable (PostgreSQL jsonb, MySQL JSON, or SQLite TEXT with the json1 functions), but accept the cost: no indexing and awkward queries.

Flatten fixed nested objects such as user.address into columns (address_city, address_zip). Split into a table only when the nesting can keep growing; splitting a fixed shape just adds JOINs.

4. NULL versus missing fields: SQL has no "absent key"

JSON lets a key be absent entirely. SQL columns are fixed. After an import both cases look identical — NULL — even though they can mean different things:

JSON situation What the database shows Suggested handling
Key is absent NULL Add a default at table definition, or a _present marker column
Key exists with null NULL Means the user explicitly cleared the value
Key exists with an empty string '' Distinguishable from NULL, but easy to confuse in queries
Numeric field missing NULL Never let 0 pretend to be "missing"

Two common mistakes:

  1. Using an empty string instead of NULL. Later, WHERE email IS NULL misses those rows, and your "users without an email" metric silently undercounts.
  2. Testing with col = NULL. In SQL's three-valued logic, NULL = NULL evaluates to NULL, not TRUE. You must write IS NULL:
SELECT COUNT(*) FROM users WHERE email IS NULL;

If you need to preserve whether a key was present at all, add a boolean marker column rather than inferring it from NULL. The API contract should state this too; see JSON API design and field naming.

5. Quoting and escaping: never concatenate SQL strings

JSON strings can contain single quotes, double quotes, backslashes, newlines, and even \u0000. With hand-built strings, one missed escape shifts the rest of the statement, and in the worst case becomes SQL injection.

In standard SQL, a single quote is escaped by doubling it:

INSERT INTO notes (id, content) VALUES (1, 'It''s a ''quoted'' value');

Production code should use parameter binding and let the driver handle escaping and types:

// PostgreSQL uses $1/$2, MySQL uses ?; this example uses node-postgres
await client.query('INSERT INTO users (id, name) VALUES ($1, $2)', [row.id, row.name]);

Only hand-escape when you must export a .sql file, and then at minimum:

  • replace ' with '';
  • if newlines are preserved, confirm the target database accepts multi-line string literals;
  • strip \u0000, since most databases reject NUL inside strings;
  • MySQL treats backslash as an escape character by default, so \ must become \\, or switch to standard mode.

Double quotes from JSON need no escaping inside a SQL single-quoted string. Adding an extra layer of escaping is how stray backslashes end up in your data.

6. Type mapping: where the six JSON values land in SQL

JSON has only string, number, boolean, null, object, and array. SQL has a much finer type system. The mistakes usually happen in precision and semantics rather than syntax:

JSON value Suggested SQL type Where it breaks
Integer INTEGER / BIGINT Above 2^53 the JavaScript side has already lost precision, so the stored value is wrong too
Decimal, money NUMERIC / DECIMAL FLOAT is binary floating point; 0.1 + 0.2 is not exact
String TEXT / VARCHAR(n) Length limits truncate; collation changes comparisons
Boolean BOOLEAN / TINYINT(1) SQLite has no boolean type and stores 0/1
null NULL Indistinguishable from a missing field
Date string DATE / TIMESTAMP / TIMESTAMPTZ Lost time zone, truncated milliseconds, invalid formats
Enum string TEXT with CHECK, or an enum table Case differences and dirty values have no constraint
Object / array A child table, or JSON / JSONB Stored as a string it cannot be indexed or queried

Booleans hide the subtlest bug: JSON false becomes 0 in MySQL TINYINT(1), round-trips look fine, and nobody notices. But the moment some code does if (value) on the string "false", it evaluates as truthy. Conversion scripts should call Boolean(value) explicitly.

PostgreSQL JSONB parses and deduplicates keys, keeping the last occurrence, which suits querying; JSON preserves the original text and key order. SQLite JSON is just TEXT plus a set of functions, with no real type constraint. Before choosing, ask whether this table needs filtering on fields inside the JSON.

7. Bulk inserts and transactions: batch, commit, make it rerunnable

As volume grows, three things decide whether an import stays controllable:

  1. Batch. A loop of N single-row INSERT statements costs N network round trips. Combine 500 to 1000 rows into one multi-row VALUES, or use the database's own bulk loader (PostgreSQL COPY, MySQL LOAD DATA, multi-row inserts inside a SQLite transaction).
  2. Use a transaction. All or nothing, so a half-loaded table never survives an error:
BEGIN;
INSERT INTO users (id, name) VALUES (1, 'Ada'), (2, 'Linus');
INSERT INTO order_items (order_id, sku, quantity) VALUES (1, 'A-1', 2);
COMMIT;
  1. Make it idempotent. Import scripts get rerun constantly. Use an upsert instead of delete-then-insert:
Database Idempotent form
PostgreSQL / SQLite INSERT ... ON CONFLICT (id) DO UPDATE SET ...
MySQL INSERT ... ON DUPLICATE KEY UPDATE ...

ON CONFLICT requires a unique constraint or primary key, otherwise it never fires. When there is no natural key, build a unique index on a business key such as email. When there is no key at all, add a stable id on the JSON side — never rely on an auto-increment id for idempotency, because a rerun will insert duplicates.

8. Dialect differences at a glance

Capability SQLite PostgreSQL MySQL
Multi-row VALUES Yes Yes Yes
Upsert ON CONFLICT ON CONFLICT ON DUPLICATE KEY UPDATE
Auto-increment key INTEGER PRIMARY KEY GENERATED ... AS IDENTITY AUTO_INCREMENT
Boolean type None, stores 0/1 boolean TINYINT(1)
Bulk import Multi-row inserts in a transaction COPY LOAD DATA
JSON support TEXT plus json1 functions json / jsonb JSON
Identifier quoting Double quotes Double quotes Backticks (default)

To keep one SQL file working across all three, at least avoid ON CONFLICT, backticks, and SERIAL. For cross-database projects, generate schema and upsert statements separately; do not expect one script to fit all.

9. Three spot checks after the import

Before deleting the source file, verify three things:

  1. Row count. SELECT COUNT(*) FROM users; should equal the JSON array length. One missing row usually means the final record was truncated.
  2. The column with the highest NULL rate. That points to inconsistent key names or unexpanded nesting. Go back and check whether the source uses both userId and user_id.
  3. Money and long identifiers. Pick the maximum and minimum values and confirm neither floating point nor integer conversion truncated them.

If you need to understand the tabular shape first, use the flattening approach in JSON, CSV, and tabular data conversion. If the audience is colleagues who just want to read the data, the four ways to convert JSON to Excel are less work than standing up a database.

Why do numbers lose precision after importing JSON into SQL?

Integers above 2^53 have already lost precision during JavaScript's JSON.parse, and writing them into BIGINT cannot restore it. Such ids should be strings in JSON and converted to BIGINT or TEXT during the import.

What should I use as a primary key when the JSON has no id?

Let the database generate one, or create a stable UUID before importing. Never use the array index as an id: a rerun reorders the data and points the wrong rows at the wrong records. A unique index on a business key (email, order number) also enables upserts.

What do I do when a bulk INSERT hits a unique-key conflict?

Replace the plain INSERT with the matching upsert for your dialect, and confirm the conflicting column has a unique constraint. Deleting first and inserting afterwards also works, but it must happen inside one transaction and you accept a brief window with no rows.

Do nested arrays always need a child table?

No. For read-only display without querying by sub-item, a JSON column is simpler. As soon as you need to filter or aggregate by tag or SKU, a child table with indexes is the sustainable choice. The deciding factor is how you query, not how the data is shaped.