> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenrows.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Zenrows API (Fetch, Extract via extract=auto, and Batch) is described in OpenAPI 3.1 at https://www.zenrows.com/openapi.json and listed in the RFC 9727 API catalog at https://www.zenrows.com/.well-known/api-catalog. Use the spec for exact parameter names and types.

# Validate extracted data with a schema

> Check that extracted data is complete and correctly typed before you store it, with Pydantic in Python or Zod in Node.js.

Put a schema between Zenrows and your database. Each row from Extract, `css_extractor` or `outputs` goes through a model that converts types (`"$69.00"` becomes `69.0`, `"Yes"` becomes `true`), checks required fields, and sends any row that doesn't fit to a flagged list for review. Your pipeline stores clean, typed data every time.

**Uses:** [Extract](/extract/setup) (`extract=auto`), [`css_extractor`](/fetch/features/css-extractor) and [`outputs`](/fetch/features/output-filters), with <a href="https://docs.pydantic.dev/" rel="nofollow">Pydantic</a> or <a href="https://zod.dev/" rel="nofollow">Zod</a>

## Ask your AI agent

With the [Zenrows MCP server](/mcp/overview) connected to Claude, Cursor or any MCP client, paste this prompt. Your agent picks the right Zenrows tools and settings on its own.

```text theme={"dark"}
Use Zenrows to get every product on https://www.scrapingcourse.com/ecommerce/ with name,
price and URL. Make sure every price is a number and every URL is absolute, and list
separately any product that is missing a field.
```

The same prompt works for thousands of products across many pages: ask your agent to check every row the same way before it saves them.

## Validate css\_extractor rows

`css_extractor` returns each selector as a list of strings. Zip the lists into rows, then validate each row. The model trims names, turns `"$1,299.00"` into `1299.0`, requires a positive price and an absolute URL.

<CodeGroup>
  ```python Python (Pydantic) theme={"dark"}
  # pip install requests pydantic
  import json
  import requests
  from pydantic import BaseModel, Field, HttpUrl, ValidationError, field_validator

  class Product(BaseModel):
      name: str = Field(min_length=1)
      price: float = Field(gt=0)
      url: HttpUrl

      @field_validator("name", mode="before")
      @classmethod
      def strip_name(cls, value):
          return value.strip() if isinstance(value, str) else value

      @field_validator("price", mode="before")
      @classmethod
      def parse_price(cls, value):
          # "$1,299.00" -> 1299.0; numbers pass through unchanged
          if isinstance(value, str):
              value = value.replace("$", "").replace(",", "").strip()
          return value

  css_extractor = {
      "names": ".product-name",
      "prices": ".product .price",
      "links": "a.woocommerce-LoopProduct-link @href",
  }
  params = {
      "apikey": "YOUR_ZENROWS_API_KEY",
      "url": "https://www.scrapingcourse.com/ecommerce/",
      "mode": "auto",
      "css_extractor": json.dumps(css_extractor),
  }
  response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
  response.raise_for_status()
  data = response.json()

  valid, flagged = [], []
  for name, price, url in zip(data["names"], data["prices"], data["links"]):
      raw = {"name": name, "price": price, "url": url}
      try:
          valid.append(Product.model_validate(raw))
      except ValidationError as error:
          flagged.append({"row": raw, "errors": error.errors(include_url=False)})

  print(f"{len(valid)} valid, {len(flagged)} flagged")
  print(valid[0].model_dump(mode="json"))
  ```

  ```javascript Node.js (Zod) theme={"dark"}
  // npm install zod@^4
  import { z } from "zod";

  const Product = z.object({
    name: z.string().trim().min(1),
    // "$1,299.00" -> 1299; numbers pass through unchanged
    price: z.preprocess(
      (v) => (typeof v === "string" ? Number(v.replace(/[$,]/g, "").trim()) : v),
      z.number().positive(),
    ),
    url: z.url(),
  });

  const cssExtractor = {
    names: ".product-name",
    prices: ".product .price",
    links: "a.woocommerce-LoopProduct-link @href",
  };
  const params = new URLSearchParams({
    apikey: "YOUR_ZENROWS_API_KEY",
    url: "https://www.scrapingcourse.com/ecommerce/",
    mode: "auto",
    css_extractor: JSON.stringify(cssExtractor),
  });
  const response = await fetch(`https://api.zenrows.com/v1/?${params}`, {
    signal: AbortSignal.timeout(180_000),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  const data = await response.json();

  const valid = [];
  const flagged = [];
  data.names.forEach((name, i) => {
    const raw = { name, price: data.prices[i], url: data.links[i] };
    const result = Product.safeParse(raw);
    if (result.success) valid.push(result.data);
    else flagged.push({ row: raw, errors: z.flattenError(result.error).fieldErrors });
  });

  console.log(`${valid.length} valid, ${flagged.length} flagged`);
  console.log(valid[0]);
  ```

  ```bash cURL theme={"dark"}
  # The raw rows that go into the schema
  curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&mode=auto&css_extractor=%7B%22names%22%3A%22.product-name%22%2C%22prices%22%3A%22.product%20.price%22%2C%22links%22%3A%22a.woocommerce-LoopProduct-link%20%40href%22%7D"
  ```
</CodeGroup>

```text Output (Python) theme={"dark"}
16 valid, 0 flagged
{'name': 'Abominable Hoodie', 'price': 69.0, 'url': 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/'}
```

## What a flagged row looks like

A row with an empty name, a price that isn't a number and a relative URL fails all three checks. The error names each field, so a reviewer, or your agent, knows what to fix:

```python Python (Pydantic) theme={"dark"}
Product.model_validate({"name": " ", "price": "Call for price", "url": "/product/blue-shirt"})
```

```text Output (trimmed) theme={"dark"}
3 validation errors for Product
name
  String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
price
  Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='Call for price', input_type=str]
url
  Input should be a valid URL, relative URL without a base [type=url_parsing, input_value='/product/blue-shirt', input_type=str]
```

With Zod, `z.flattenError(result.error).fieldErrors` gives the same answer as an object:

```text Output (Node.js) theme={"dark"}
{
  name: [ 'Too small: expected string to have >=1 characters' ],
  price: [ 'Invalid input: expected number, received NaN' ],
  url: [ 'Invalid URL' ]
}
```

## Validate Extract output

`extract=auto` already returns numbers for prices and `null` for fields a page leaves empty. A schema pins the fields your pipeline relies on, fills defaults (a `null` or missing currency becomes `"USD"`), and drops every other field, so new fields from Extract never break your storage. This one requires a name, a positive price, a SKU and a product URL, and saves valid and flagged rows to separate files.

<CodeGroup>
  ```python Python (Pydantic) theme={"dark"}
  # pip install requests pydantic
  import json
  from typing import Optional

  import requests
  from pydantic import BaseModel, Field, HttpUrl, ValidationError, field_validator

  class Product(BaseModel):
      name: str = Field(min_length=1)
      price: float = Field(gt=0)
      currency: str = "USD"
      sku: str = Field(min_length=1)
      product_url: HttpUrl
      availability: Optional[str] = None  # null when the page doesn't say

      @field_validator("currency", mode="before")
      @classmethod
      def default_currency(cls, value):
          return value or "USD"  # null becomes "USD" too

  params = {
      "apikey": "YOUR_ZENROWS_API_KEY",
      "url": "https://www.scrapingcourse.com/ecommerce/?s=jacket&post_type=product",
      "mode": "auto",
      "extract": "auto",
  }
  response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
  response.raise_for_status()
  products = response.json()["parsed"]["products"]

  valid, flagged = [], []
  for raw in products:
      try:
          valid.append(Product.model_validate(raw).model_dump(mode="json"))
      except ValidationError as error:
          flagged.append({"row": raw, "errors": error.errors(include_url=False)})

  with open("products.json", "w") as f:
      json.dump(valid, f, indent=2)
  with open("flagged.json", "w") as f:
      json.dump(flagged, f, indent=2, default=str)

  print(f"{len(valid)} valid, {len(flagged)} flagged")
  print(valid[0])
  ```

  ```javascript Node.js (Zod) theme={"dark"}
  // npm install zod@^4
  import { writeFile } from "node:fs/promises";
  import { z } from "zod";

  const Product = z.object({
    name: z.string().min(1),
    price: z.number().positive(),
    currency: z.string().nullish().transform((v) => v ?? "USD"), // null or missing becomes "USD"
    sku: z.string().min(1),
    product_url: z.url(),
    availability: z.string().nullable().default(null), // null when the page doesn't say
  });

  const params = new URLSearchParams({
    apikey: "YOUR_ZENROWS_API_KEY",
    url: "https://www.scrapingcourse.com/ecommerce/?s=jacket&post_type=product",
    mode: "auto",
    extract: "auto",
  });
  const response = await fetch(`https://api.zenrows.com/v1/?${params}`, {
    signal: AbortSignal.timeout(180_000),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  const { parsed } = await response.json();

  const valid = [];
  const flagged = [];
  for (const raw of parsed.products) {
    const result = Product.safeParse(raw);
    if (result.success) valid.push(result.data);
    else flagged.push({ row: raw, errors: z.flattenError(result.error).fieldErrors });
  }

  await writeFile("products.json", JSON.stringify(valid, null, 2));
  await writeFile("flagged.json", JSON.stringify(flagged, null, 2));
  console.log(`${valid.length} valid, ${flagged.length} flagged`);
  console.log(valid[0]);
  ```
</CodeGroup>

```text Output (Python) theme={"dark"}
16 valid, 0 flagged
{'name': 'Olivia 1/4 Zip Light Jacket', 'price': 77.0, 'currency': 'USD', 'sku': 'WJ12', 'product_url': 'https://www.scrapingcourse.com/ecommerce/product/olivia-1-4-zip-light-jacket/', 'availability': None}
```

## Normalize table rows

Rows from `outputs=tables` are keyed by the column headings on the page, and every value is a string. Field aliases map headings such as `"Product ID"` to clean names, and Pydantic turns `"Yes"`/`"No"` into booleans on its own:

```python Python (Pydantic) theme={"dark"}
# pip install requests pydantic
import requests
from pydantic import BaseModel, Field, field_validator

class Item(BaseModel):
    product_id: str = Field(alias="Product ID")
    name: str = Field(alias="Name", min_length=1)
    category: str = Field(alias="Category")
    price: float = Field(alias="Price", gt=0)
    in_stock: bool = Field(alias="In Stock")  # "Yes"/"No" become True/False

    @field_validator("price", mode="before")
    @classmethod
    def parse_price(cls, value):
        return value.replace("$", "").replace(",", "") if isinstance(value, str) else value

params = {
    "apikey": "YOUR_ZENROWS_API_KEY",
    "url": "https://www.scrapingcourse.com/table-parsing",
    "mode": "auto",
    "outputs": "tables",
}
response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
response.raise_for_status()

rows = response.json()["tables"][0]["Content"]
items = [Item.model_validate(row) for row in rows]
print(len(items), "rows")
print(items[2].model_dump())
```

```text Output theme={"dark"}
15 rows
{'product_id': '003', 'name': 'Headphones', 'category': 'Audio', 'price': 149.99, 'in_stock': False}
```

## Example output

The first rows of `products.json` from the Extract run above. Every row is typed, carries the `USD` default, and keeps only the schema's fields:

```json theme={"dark"}
[
  {
    "name": "Olivia 1/4 Zip Light Jacket",
    "price": 77,
    "currency": "USD",
    "sku": "WJ12",
    "product_url": "https://www.scrapingcourse.com/ecommerce/product/olivia-1-4-zip-light-jacket/",
    "availability": null
  },
  {
    "name": "Neve Studio Dance Jacket",
    "price": 69,
    "currency": "USD",
    "sku": "WJ11",
    "product_url": "https://www.scrapingcourse.com/ecommerce/product/neve-studio-dance-jacket/",
    "availability": null
  }
]
```

`flagged.json` holds `[]` for this page. Any row that fails the schema lands there with its errors, as shown in [What a flagged row looks like](#what-a-flagged-row-looks-like).

## Customize it

| Change | How |
| - | - |
| Reject unknown fields instead of dropping them | Pydantic: `model_config = ConfigDict(extra="forbid")`. Zod: `z.strictObject({...})` |
| Keep every field Extract returns | Pydantic: `ConfigDict(extra="allow")`. Zod: `z.looseObject({...})` |
| A fixed set of allowed values | Pydantic: `Literal["in_stock", "out_of_stock"]`. Zod: `z.enum(["in_stock", "out_of_stock"])` |
| Fewer fields to validate | Add `extract_fields`, see [Get structured JSON from any page](/academy/extract/auto-extraction#keep-just-the-fields-you-need) |
| Validate a whole catalog | Run each Batch result through the same model, see [Turn search and listing pages into JSON](/academy/extract/listing-pages) |
| Retry the pages behind flagged rows | Re-fetch those URLs with `js_render=true` and `wait_for`, see [Retry failed requests](/academy/retry-failed-requests) |

## Troubleshooting

* **Every row is flagged on `price`**: the price arrives in a format your parser doesn't expect, such as `"69,00 €"`. Print one raw value and adjust the `parse_price` step for that currency format.
* **A required field is `null` on many rows**: the page leaves it empty, or loads it late. Make the field `Optional` with a default, or fetch with `js_render=true` and `wait_for` so it renders first.
* **URLs fail as relative**: the selector returned paths such as `/product/blue-shirt`. Join them with the page URL (`urljoin` in Python, `new URL(path, page)` in Node.js) before validation.
* **Names and prices don't line up**: one card has no price, so the lists differ in length. Use `extract=auto`, which returns one object per item, or parse one card at a time as in [Extract data with CSS selectors](/academy/how-to-extract-data#using-external-libraries).

## Related recipes

<CardGroup cols={2}>
  <Card title="Get structured JSON from any page" icon="wand" href="/academy/extract/auto-extraction">
    Get product, listing and article fields as JSON.
  </Card>

  <Card title="Turn search and listing pages into JSON" icon="list-details" href="/academy/extract/listing-pages">
    Collect every item across every page into one dataset.
  </Card>

  <Card title="Pull tables and links from any page" icon="table" href="/academy/extract/tables-to-csv">
    Save every table on a page as CSV.
  </Card>

  <Card title="Extract data with CSS selectors" icon="code" href="/academy/how-to-extract-data">
    Pick exact elements with css\_extractor.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.