From a JSON payload to a typed data model
Prisma is the ORM of choice for a lot of TypeScript backends, and its schema — the schema.prisma file — is where you declare your models. A very common starting point is a JSON sample: an API response or a document you want to persist. Converting that JSON into a Prisma model is mostly a mechanical type-mapping, plus a few database concerns that JSON simply doesn't carry. Here's the full mapping. If you've used JSON to TypeScript, this is the same idea aimed at your database layer.
The scalar mapping
Given this JSON:
{
"id": 1,
"email": "ada@example.com",
"name": "Ada",
"age": 36,
"isActive": true,
"rating": 4.5,
"createdAt": "2026-08-03T10:00:00Z"
}each field maps to a Prisma scalar type:
model User {
id Int @id @default(autoincrement())
email String @unique
name String
age Int
isActive Boolean
rating Float
createdAt DateTime @default(now())
}The core rules:
| JSON value | Prisma type |
|---|---|
"text" | String |
36 (whole) | Int |
4.5 (decimal) | Float (or Decimal for money) |
true / false | Boolean |
| ISO date string | DateTime |
null / may be absent | optional ? (below) |
Note Float is wrong for money — use Decimal to avoid the floating-point precision problems that bite currency math.
Optional fields and arrays
A field that can be null or missing becomes optional with a trailing ?. A JSON array of scalars becomes a list with []:
{ "nickname": null, "tags": ["admin", "beta"] }model User {
// ...
nickname String? // optional — nullable in the database
tags String[] // scalar list (Postgres/CockroachDB)
}As always with generated models, feed the converter a representative sample: if nickname is null in your one example but usually present, you still want it optional. Getting nullability right is the main judgement call.
Nested objects: relations or Json?
This is the decision JSON can't make for you. A nested object like:
{ "id": 1, "profile": { "bio": "...", "avatar": "..." } }can map two ways, and you must choose based on how you'll use it:
As a relation (separate table) — when the nested object is its own entity you'll query, filter, or join:
model User {
id Int @id @default(autoincrement())
profile Profile?
}
model Profile {
id Int @id @default(autoincrement())
bio String
avatar String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}As a `Json` column — when the nested data is variable, document-like, and you never query inside it:
model User {
id Int @id @default(autoincrement())
profile Json // stored as a JSON/JSONB column
}Rule of thumb: query it → relation; just store it → `Json`. This mirrors the JSON in databases trade-off between typed columns and JSON columns.
What a JSON sample can't tell you
A JSON object shows *shape*, not *database semantics*, so you always add:
- A primary key —
@id, usually with@default(autoincrement())or@default(uuid()). - Uniqueness —
@uniqueon emails, usernames, slugs. - Defaults —
@default(now())for timestamps,@default(false)for flags. - Indexes —
@@index([...])for fields you filter on. - The right precision —
Decimalfor money,BigIntfor 64-bit IDs.
These are decisions about your data model, and no converter can infer them from a single payload — treat the generated model as a scaffold you then annotate.
Tips
- Start from a real response, including optional and edge-case fields, so nullability is correct.
- Decide relation vs `Json` per nested object before you migrate — it's painful to change later.
- Fix up money and large IDs to
DecimalandBigInt/Stringimmediately. - Then run `prisma migrate` to turn the model into tables.
For adjacent conversions see JSON to SQL, JSON to TypeScript interfaces, and JSON to Zod for validating the same data at the API boundary.