Skip to content
Start free

Schema Design for Accurate Extraction

The schema you pass to /extract/json and /generate/json is the most important factor in extraction quality. Learn the patterns that produce reliable results.

The schema you pass to /extract/json and /generate/json is the most important factor in extraction quality. It’s not just a shape definition; it’s a set of instructions for the AI doing the extraction.

This guide covers the patterns that produce reliable results and the common mistakes that produce noise.


Before writing a schema from scratch, check whether one already exists. The Tabstack schema library publishes hand-built extraction schemas grouped by category, and the CLI pulls them into a local store:

Terminal window
tabstack schema list
tabstack schema pull job-posting
tabstack extract json https://example.com/careers/1 --schema-name job-posting

Treat a pulled schema as a first draft. It encodes a reasonable shape for that kind of page, and it knows nothing about the specific site you are pointing it at. The rest of this guide is how to close that gap: the descriptions, null handling, depth, and constraints that decide whether the JSON you get back is the JSON you wanted.

tabstack schema status shows which of your local copies you have edited and which have changed upstream, so adapting a schema does not mean losing track of where it came from.


Descriptions are instructions, not documentation

Section titled “Descriptions are instructions, not documentation”

Every property description you add tells the extraction AI what to look for and how to interpret it. Without descriptions, the AI has only the property name to go on, and names are ambiguous.

// Ambiguous: AI guesses what 'price' means
json_schema: {
type: 'object',
properties: {
price: { type: 'number' }
}
}
// Specific: AI knows exactly what to extract
json_schema: {
type: 'object',
properties: {
price: {
type: 'number',
description: 'Monthly price in USD as a number. Null if pricing requires contacting sales.'
}
}
}

The second version extracts correctly even when the price is presented as "$49/mo", a range, or missing entirely. The description tells the AI how to handle each case.


Pages don’t always have every field. Tell the AI what to return when data is absent.

properties: {
annual_discount: {
type: ['number', 'null'],
description: 'Percentage discount for annual billing (e.g. 20 for 20% off). Null if no annual option is offered.'
},
trial_days: {
type: ['number', 'null'],
description: 'Length of free trial in days. Null if no trial is available.'
}
}

Declaring null in the type and describing when it applies is the strongest signal you can give the extractor. It is not a guarantee. Read the next section before you write validation logic against it.


What you get back when a field can’t be filled

Section titled “What you get back when a field can’t be filled”

This is the part to understand before you put /extract/json in a pipeline.

A call never fails because the page was missing your data. You get a 200 and an object shaped like your schema. There is no “no match” status, no partial-match flag, and no list of fields the extractor couldn’t fill. Marking a field required does not make the call fail either; it takes null off the table, so the extractor has to put a value there.

What lands in an unfillable field depends on whether it is required. Observed against the live API on 2026-10-21:

Schema typeOptional field, absent from the pageSame field marked required
stringnull or omittednull
numbernull or omitted0
booleannull or omittedfalse
arraynull, [], or omitted[]
objectnull or omittedObject present, leaves filled by the rules above

Optional fields tell you when data is missing. A field you leave out of required comes back null when the page doesn’t have it, or is left out of the object entirely. Treat a missing key the same as null.

Required fields trade that for a value. A required number comes back as 0 and a required boolean as false even when the page never mentions them, so neither is distinguishable from a genuine 0 or a genuine false:

// founded_year is required, so a page that never mentions it returns 0
const age = 2026 - data.founded_year; // 2026, not "unknown"
// employee_count is required, so this row silently fails the filter
if (data.employee_count > 50) enrich(row);

Only mark a field required when you need a value in every row and can tell a placeholder from real data.

An optional boolean has three possible values:

  • true: the page shows it.
  • false: the page covers this kind of information and this one isn’t there. For example, a model card lists its capability tags and vision is not among them.
  • null: the page doesn’t cover the topic at all.

Telling false from null is a judgment about what the page covers, and the extractor makes it once for the whole field rather than row by row. It is usually right, but a field the page doesn’t really cover can occasionally come back false on every row. If the difference matters to you:

  • Say what false means in the description. "true if the card shows the vision tag, false if it doesn't" leaves less to judgment than "true if the card shows the vision tag".
  • Mark the field required when you want strict true/false and an unknown counting as false is acceptable.
  • Check a page you know doesn’t cover the topic and confirm the field comes back null there.
  • Handle null and missing keys together for every optional field. In SQL, remember that NOT NULL is NULL: WHERE NOT supports_vision skips rows where the value is null.
  • Treat 0 and false in required fields as suspect for any value that could legitimately be absent from the page.
  • Give absence its own field when a distinction actually matters to you. A has_pricing boolean the extractor can affirmatively set is more trustworthy than inferring absence from a missing price.
  • Sanity-check against a known-empty page while building. Run your schema against a URL you know lacks the fields and record what each one returns, so your validation matches real behavior rather than assumed behavior.

Schema problems and page problems fail differently:

RequestResponse
json_schema omitted400 { "error": "json_schema is required" }
json_schema isn’t a JSON object400 { "error": "invalid JSON request body" }
json_schema contains an unknown type keyword200, field omitted from the response

The third row is the one that bites: an invalid schema is not rejected. A typo like { "type": "banana" } returns 200 with that field missing rather than a validation error, so a broken schema fails quietly on every call instead of loudly on the first. Test a new schema against a page you know well before running it at volume.

See Error Reference for the full error shape.


Match your schema depth to the page structure

Section titled “Match your schema depth to the page structure”

If the page has a two-level hierarchy (categories containing products), your schema should reflect that:

json_schema: {
type: 'object',
properties: {
categories: {
type: 'array',
description: 'Top-level product categories on the page',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Category heading' },
products: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Product name' },
price: { type: 'number', description: 'Price in USD' }
}
}
}
}
}
}
}
}

A flat schema trying to capture a nested page will produce unreliable results: items get merged and hierarchy is lost.


For fields with a known set of values, enum dramatically improves consistency:

properties: {
billing_period: {
type: 'string',
enum: ['monthly', 'annual', 'one-time', 'unknown'],
description: 'How often the plan is billed. Use unknown if unclear.'
},
tier: {
type: 'string',
enum: ['free', 'starter', 'pro', 'enterprise'],
description: 'Plan tier. Use enterprise for anything requiring sales contact.'
}
}

Without enum, you’ll get "monthly", "Monthly", "per month", "billed monthly": all meaning the same thing but inconsistent to process downstream.


Broad array item schemas produce noisy results. The AI will include anything that loosely matches.

// Too broad: captures navigation links, footer links, ads
links: {
type: 'array',
items: { type: 'string' }
}
// Tight: captures only what you want
documentation_links: {
type: 'array',
description: 'Links to API documentation pages only, not navigation, marketing, or footer links',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Link text' },
url: { type: 'string', description: 'Absolute URL' }
}
}
}

Add context about the page in a top-level description

Section titled “Add context about the page in a top-level description”

You can add a description at the top level of your schema to give the AI page-level context:

json_schema: {
type: 'object',
description: 'Pricing information from a SaaS product pricing page. Focus on subscription plans only. Ignore one-time add-ons and enterprise custom pricing blocks.',
properties: {
plans: { /* ... */ }
}
}

This is especially useful when a page has multiple sections and you want to scope extraction to a specific one.


If results are incomplete or inaccurate:

  1. Add more specific descriptions. This is the most common fix.
  2. Upgrade effort to max. Content may not be in the initial HTML.
  3. Simplify the schema. Remove fields you don’t need; fewer fields means less for the AI to get wrong.
  4. Add nocache: true. This confirms the issue isn’t a stale cached result.
  5. Check for JS rendering. Open the URL and disable JavaScript in your browser to see what the extractor sees at min/standard.