Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 9 – CSV and tabular text

Typed CSV: CSVW metadata and Table Schema

Describe the columns, types and keys of a CSV file with W3C CSVW metadata or a Table Schema, validate it row by row, and convert it to JSON by rule.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answer to its interview question.

What you will learn

  • Describe a CSV file's columns, types and keys in W3C CSVW metadata
  • Validate a CSV file against a Table Schema and report every error by row and column
  • Convert typed CSV rows to JSON with an explicit, documented mapping
  • Choose between CSVW metadata, a Table Schema and JSON Schema for a file you publish

Before you start

What you’ll learn · Before you start

What you will learn

  • Describe a CSV file's columns, types and keys in W3C CSVW metadata
  • Validate a CSV file against a Table Schema and report every error by row and column
  • Convert typed CSV rows to JSON with an explicit, documented mapping
  • Choose between CSVW metadata, a Table Schema and JSON Schema for a file you publish

Before you start

On this page

The file invoices.csv has six columns and no way to say that amount is a decimal with commas between thousands, that issued is written day first, or that no two rows may share an invoice_id. Every program that reads it has to be told those facts, and each one is told differently, so five readers check the file in five ways.

CSVW metadata: A JSON file, of the media type application/csvm+json, that describes CSV files by the W3C's CSV on the Web Recommendations: their columns, the datatype and format of each, required cells, keys and the dialect. and a Table Schema: The Frictionless Data specification, part of the Data Package standard, that describes a table as JSON: fields with types, formats and constraints, the texts that mean missing, and primary and foreign keys. put those facts in a JSON file beside the CSV, where every program can find them. You will describe the file both ways, validate it with a report that names the row and column of each of its 7 errors, convert the rows that pass into JSON by a documented rule, and choose which description fits a file you publish.

A CSVW metadata file describes the tables beside the CSV

CSV on the Web, CSVW for short, is a set of W3C Recommendations of the CSV on the Web Working Group. The Model for Tabular Data and Metadata on the Web says how a table is parsed and checked, and the Metadata Vocabulary for Tabular Data says how to describe one in JSON, in a file of the registered media type application/csvm+json. A processor looks for that file in a fixed order: metadata the user supplies, then a Link header with rel="describedby", then the places a site lists at /.well-known/csvm, and by default invoices.csv-metadata.json and then csv-metadata.json beside the file.

Here is a CSVW metadata file for the invoices and the customers they refer to, with the two CSV files it describes:

A table group: the metadata and its two CSV files

JSON · typed/csv-metadata.json

{
  "@context": "http://www.w3.org/ns/csvw",
  "tables": [
    {
      "url": "customers.csv",
      "tableSchema": {
        "columns": [
          { "name": "customer_id", "titles": "customer_id", "required": true },
          { "name": "name", "titles": "name", "required": true }
        ],
        "primaryKey": "customer_id"
      }
    },
    {
      "url": "invoices.csv",
      "dialect": { "encoding": "utf-8", "header": true },
      "tableSchema": {
        "columns": [
          { "name": "invoice_id", "titles": "invoice_id", "required": true,
            "datatype": { "base": "string", "format": "^INV-[0-9]{3}$" } },
          { "name": "customer_id", "titles": "customer_id", "required": true },
          { "name": "issued", "titles": "issued", "required": true,
            "datatype": { "base": "date", "format": "dd/MM/yyyy" } },
          { "name": "amount", "titles": "amount", "required": true,
            "datatype": { "base": "decimal", "format": { "groupChar": "," }, "minimum": 0 } },
          { "name": "paid", "titles": "paid", "datatype": { "base": "boolean", "format": "Y|N" } },
          { "name": "note", "titles": "note" }
        ],
        "primaryKey": "invoice_id",
        "foreignKeys": [
          { "columnReference": "customer_id",
            "reference": { "resource": "customers.csv", "columnReference": "customer_id" } }
        ]
      }
    }
  ]
}

CSV (invoices.csv) · typed/invoices.csv

invoice_id,customer_id,issued,amount,paid,note
INV-001,C-01,04/10/2026,"1,250.50",Y,first order
INV-002,C-02,13/10/2026,99.00,N,
INV-003,C-07,31/10/2026,abc,Y,
INV-003,C-01,01/11/2026,450.00,maybe,
INV-05,,15/11/2026,-20.00,N,refund

CSV (customers.csv) · typed/customers.csv

customer_id,name
C-01,Asha Traders
C-02,Ravi Stores

The file describes a table group: two tables, each with its url and its tableSchema. Each column has a name and titles, the header text the column is known by, and the datatype says what its cells hold: a base such as string, date, decimal or boolean, and a format that says how the text is written. The string format of invoice_id is a regular expression, the date format dd/MM/yyyy uses Unicode date symbols, the decimal format names the comma as groupChar, and the boolean format Y|N names the true value and then the false one. required: true forbids an empty cell, because by default an empty cell is null. The primary key: The column, or the set of columns, whose values identify each row of a table: no two rows may have the same key. invoice_id must be unique, and the foreign key: A column whose values must be the key of a row in another table, such as a customer_id that must exist in customers.csv. says that each customer_id must be the key of a row in customers.csv.

Section 6.4 of the model gives the steps every cell goes through. Step through five cells of invoices.csv:

Interactive · stage by stage Five cells of invoices.csv, parsed by their columns Step through five cells. Each step says which rule of the column decides, and what the cell becomes.
Five cells of invoices.csv, parsed by their columns, step 7 of 7. Showing The cell's text (row 4, paid: maybe), Empty or null?, Required? (required, but empty), Parse by the format (maybe: not Y or N), Check the limits (-20.00 is below 0), A typed value (Decimal 1250.50). In focus: A typed value.The cell's textrow 4, paid: maybeEmpty or null?null is "" unless setRequired?required, but emptyParse by the formatmaybe: not Y or NCheck the limits-20.00 is below 0A typed valueDecimal 1250.50

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. An amount with a group sign. Row 1 has the amount 1,250.50. It is not empty, so the column's datatype decides: a decimal whose format names the comma as groupChar.
  2. Grouping removed, value typed. The parser removes the group commas, reads 1250.50 as a decimal and checks the minimum, 0. The cell becomes the value 1250.50, a number now and no longer text.
  3. An empty required cell. Row 5 has no customer_id. An empty cell is null by default, and the column is required, so the validator records an error and moves on to the next cell.
  4. Text where a decimal belongs. Row 3 has the amount abc. Nothing in it reads as a decimal, so parsing fails. The cell keeps its text, and the error names the row and the column.
  5. A value below the minimum. Row 5 has the amount -20.00. It parses, but the column says minimum 0, so the limit check records the third error of the row.
  6. A boolean with its own words. Row 4 has paid = maybe. The column is a boolean whose format is Y|N: Y means true and N false, and anything else is an error.
  7. Errors, and the checking goes on. The specification says a validator must raise these errors and may go on checking, so one run lists every problem of the file. Keys are checked after the cells: a repeated invoice_id and an unknown customer.

Four of the five cells broke a rule of their column, and none of them stopped the parsing: each error was noted with its row and column, and the next cell was read.

Checkpoint

Check: CSVW metadata

  1. Question 1 of 2 No metadata is supplied, the server sends no Link header and the site has no /.well-known/csvm file. Where does a CSVW processor look for the metadata of invoices.csv?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: invoices.csv-metadata.json, then csv-metadata.json, beside the file

    The order is: metadata the user supplies, a Link header with rel="describedby", the locations of /.well-known/csvm or by default {+url}-metadata.json and csv-metadata.json, and last the file's own header.

  2. Question 2 of 2 A column has required: true and no null property. Row 5 has an empty cell in it. What does a validator report?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An error, because an empty cell is null by default and the column may not be null

    csvw_check.py reported row 5, customer_id: required, but empty. Empty means null unless the metadata says otherwise, and required means not null.

A validator parses every cell by its column and reports the row and column

The engine of this lesson, typedcsv.py, implements the parts of the specifications that these files use: string patterns, day-first dates, decimals with a group character, booleans with their own words, required cells, a minimum, a primary key and a foreign key. It turns either description into one model of a column, then parses every cell by its column and checks the keys:

typedcsv.py: one engine for both descriptions Python · typed/typedcsv.py
"""Typed CSV for the parts of CSVW and Table Schema this lesson uses: one model of a column, read from either
description, then every cell parsed by its column and every key checked, with the row and column of each error."""

import csv
import io
import re
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal


@dataclass
class Column:
    name: str
    base: str = "string"  # string, decimal, date or boolean
    required: bool = False
    pattern: str | None = None  # a regular expression the whole text must match (string)
    date_format: str | None = None  # a strptime pattern, and the form it stands for (date)
    date_form: str | None = None
    group_char: str | None = None  # removed before the number is read (decimal)
    true_false: tuple = ("true", "false")  # the texts for True and False (boolean)
    minimum: Decimal | None = None


@dataclass
class Table:
    columns: list
    primary_key: list
    foreign_keys: list  # (columns here, table there, columns there)


CSVW_DATE = {"dd": (r"\d{2}", "%d"), "MM": (r"\d{2}", "%m"), "yyyy": (r"\d{4}", "%Y")}


def from_csvw(metadata, url):
    """The table of a CSVW table group whose url is `url`."""
    table = next(t for t in metadata["tables"] if t["url"] == url)["tableSchema"]
    columns = []
    for c in table["columns"]:
        dt = c.get("datatype", "string")
        dt = {"base": dt} if isinstance(dt, str) else dt
        col = Column(c["name"], dt["base"], c.get("required", False))
        fmt = dt.get("format")
        if col.base == "string" and fmt:
            col.pattern = fmt.removeprefix("^").removesuffix("$")
        elif col.base == "date":
            parts = re.split(r"(dd|MM|yyyy)", fmt)
            col.date_format = "".join(CSVW_DATE[p][1] if p in CSVW_DATE else p for p in parts)
            col.date_form = fmt
            col.pattern = "".join(CSVW_DATE[p][0] if p in CSVW_DATE else re.escape(p) for p in parts)
        elif col.base == "decimal":
            col.group_char = (fmt or {}).get("groupChar")
        elif col.base == "boolean" and fmt:
            col.true_false = tuple(fmt.split("|"))
        if "minimum" in dt:
            col.minimum = Decimal(str(dt["minimum"]))
        columns.append(col)
    key = table.get("primaryKey", [])
    fks = [([fk["columnReference"]], fk["reference"]["resource"], [fk["reference"]["columnReference"]])
           for fk in table.get("foreignKeys", [])]
    return Table(columns, [key] if isinstance(key, str) else key, fks)


def from_table_schema(schema):
    """The table a Frictionless Table Schema describes."""
    columns = []
    for f in schema["fields"]:
        rules = f.get("constraints", {})
        base = {"number": "decimal"}.get(f.get("type", "string"), f.get("type", "string"))
        col = Column(f["name"], base, rules.get("required", False), rules.get("pattern"))
        if base == "date":
            col.date_format = f.get("format", "%Y-%m-%d")
            col.date_form = col.date_format
        col.group_char = f.get("groupChar")
        if base == "boolean":
            col.true_false = (f["trueValues"][0], f["falseValues"][0])
        if "minimum" in rules:
            col.minimum = Decimal(str(rules["minimum"]))
        columns.append(col)
    fks = [(fk["fields"], fk["reference"]["resource"], fk["reference"]["fields"]) for fk in schema.get("foreignKeys", [])]
    return Table(columns, schema.get("primaryKey", []), fks)


def parse(col, text):
    """The typed value of one cell, and the reason it is wrong (None when it is right)."""
    if text == "":
        return None, "required, but empty" if col.required else None
    if col.pattern and not re.fullmatch(col.pattern, text):
        if col.base == "date":
            return text, f"{text!r} is not a date written {col.date_form}"
        return text, f"{text!r} does not match {col.pattern}"
    if col.base == "date":
        try:
            return datetime.strptime(text, col.date_format).date(), None
        except ValueError:
            return text, f"{text!r} is not a date written {col.date_form}"
    if col.base == "boolean":
        if text not in col.true_false:
            return text, f"{text!r} is not {col.true_false[0]} or {col.true_false[1]}"
        return text == col.true_false[0], None
    if col.base == "decimal":
        plain = text.replace(col.group_char, "") if col.group_char else text
        if not re.fullmatch(r"-?\d+(?:\.\d+)?", plain):
            return text, f"{text!r} is not a decimal"
        value = Decimal(plain)
        if col.minimum is not None and value < col.minimum:
            return value, f"{text} is below the minimum {col.minimum}"
        return value, None
    return text, None


def check(text, table, others):
    """The typed rows of `text` and every error, as "row n, column: reason" (row 1 is the first after the header)."""
    reader = csv.reader(io.StringIO(text, newline=""))
    header = next(reader)
    names = [c.name for c in table.columns]
    errors = [] if header == names else [(0, 0, f"header {header} is not {names}")]
    rows = []
    for n, fields in enumerate(reader, start=1):
        row = {}
        for i, (col, cell) in enumerate(zip(table.columns, fields)):
            row[col.name], problem = parse(col, cell)
            if problem:
                errors.append((n, i, f"row {n}, {col.name}: {problem}"))
        rows.append(row)
    seen = {}
    for n, row in enumerate(rows, start=1):
        key = tuple(row[k] for k in table.primary_key)
        if key in seen:
            errors.append((n, names.index(table.primary_key[0]), f"row {n}, {'+'.join(table.primary_key)}: {key[0]!r} is already the key of row {seen[key]}"))
        seen.setdefault(key, n)
    for here, there, their in table.foreign_keys:
        keys = {tuple(r[c] for c in their) for r in others[there]}
        for n, row in enumerate(rows, start=1):
            value = tuple(row[c] for c in here)
            if None not in value and value not in keys:
                errors.append((n, names.index(here[0]), f"row {n}, {here[0]}: {value[0]!r} is not a {their[0]} of {there}"))
    return rows, [message for _, _, message in sorted(errors)]


def read_rows(text):
    """The records of a CSV text as dicts of strings, by its header."""
    return list(csv.DictReader(io.StringIO(text, newline="")))

The model says what a validator does with a bad cell: it must raise an error but may go on validating. So the check collects every error, with the row and the column, instead of stopping at the first:

invoices.csv against its CSVW metadata Python · typed/csvw_check.py
"""invoices.csv checked against its CSVW metadata, csv-metadata.json, which also describes customers.csv."""

import json
from pathlib import Path

from typedcsv import check, from_csvw, read_rows

metadata = json.loads(Path("csv-metadata.json").read_text(encoding="utf-8"))
invoices = from_csvw(metadata, "invoices.csv")
customers = read_rows(Path("customers.csv").read_text(encoding="utf-8"))

rows, errors = check(Path("invoices.csv").read_text(encoding="utf-8"), invoices, {"customers.csv": customers})
print(f"{len(rows)} rows, {len(errors)} errors")
for error in errors:
    print(" ", error)
print("row 1 typed:", rows[0])

Output

5 rows, 7 errors
  row 3, customer_id: 'C-07' is not a customer_id of customers.csv
  row 3, amount: 'abc' is not a decimal
  row 4, invoice_id: 'INV-003' is already the key of row 3
  row 4, paid: 'maybe' is not Y or N
  row 5, invoice_id: 'INV-05' does not match INV-[0-9]{3}
  row 5, customer_id: required, but empty
  row 5, amount: -20.00 is below the minimum 0
row 1 typed: {'invoice_id': 'INV-001', 'customer_id': 'C-01', 'issued': datetime.date(2026, 10, 4), 'amount': Decimal('1250.50'), 'paid': True, 'note': 'first order'}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 csvw_check.py

Five rows, seven errors, and every message says where to look. Row 3 has a customer, C-07, that customers.csv does not have, and an amount, abc, that is not a decimal. Row 4 repeats the key INV-003 of row 3 and says maybe where the column allows Y or N. Row 5 breaks three rules at once: INV-05 has two digits instead of three, its customer is empty although required, and -20.00 is below the minimum 0. Rows 1 and 2 passed, and row 1 came back typed: a date, a Decimal('1250.50') with its group comma gone, and True for Y. Row numbers count the records after the header, so row 3 is on line 4 of the file.

Drop one rule

Press Edit and add the line invoices.columns[3].minimum = None just before rows, errors = check(…), then run it. The report falls to six errors: without its minimum, the amount column accepts -20.00.

A rule that the description does not name is never checked, so the description has to say everything a reader of the file may rely on.

Stopping at the first error

Wrong: raising an exception on the first bad cell, so the sender fixes one problem per upload and sends the file seven times. Right: check every cell and every key, then refuse the file once, with a report that lists each error by row and column.

The rules come from the description and nowhere else, so changing a rule means changing one file, and every reader follows.

Key idea

A description beside the file turns every reader’s guesses into one set of rules: each cell is parsed by its column, the keys are checked across the rows, and the report names the row and column of every error.

Checkpoint

Check: Validating cell by cell, then the keys

  1. Question 1 of 2 Which errors did the validator report for row 4, INV-003,C-01,01/11/…,450.00,maybe?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Its paid value maybe is neither Y nor N
    • Its invoice_id is already the key of row 3

    One row can break several rules, and a good report lists all of them. Row 4 broke the primary key and the boolean format; row 5 broke three rules at once.

  2. Question 2 of 2 Why does check() test the primary and foreign keys only after it has parsed every cell?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because a key compares values across rows, and a foreign key with another file, so one cell cannot decide it

    Cell rules look at one value, key rules at many. Parse the cells first, so the keys compare typed values, then check the keys of the whole table and the tables it refers to.

Table Schema says the same with fields, constraints and keys

Table Schema comes from the Frictionless Data project and is part of the Data Package standard, now at version 2. It describes a table as a JSON object of fields, each with a name, a type, a format and constraints, and with missingValues, primaryKey and foreignKeys for the whole table; IANA registers the media type application/vnd.tableschema+json. Here is the same file described as a Table Schema, checked by the same engine:

invoices.schema.json: a Table Schema JSON · typed/invoices.schema.json
{
  "$schema": "https://datapackage.org/profiles/2.0/tableschema.json",
  "fields": [
    { "name": "invoice_id", "type": "string", "constraints": { "required": true, "pattern": "INV-[0-9]{3}" } },
    { "name": "customer_id", "type": "string", "constraints": { "required": true } },
    { "name": "issued", "type": "date", "format": "%d/%m/%Y", "constraints": { "required": true } },
    { "name": "amount", "type": "number", "groupChar": ",", "constraints": { "required": true, "minimum": 0 } },
    { "name": "paid", "type": "boolean", "trueValues": ["Y"], "falseValues": ["N"] },
    { "name": "note", "type": "string" }
  ],
  "missingValues": [""],
  "primaryKey": ["invoice_id"],
  "foreignKeys": [
    { "fields": ["customer_id"], "reference": { "resource": "customers", "fields": ["customer_id"] } }
  ]
}
invoices.csv against its Table Schema Python · typed/tableschema_check.py
"""The same invoices.csv checked against a Frictionless Table Schema, invoices.schema.json."""

import json
from pathlib import Path

from typedcsv import check, from_table_schema, read_rows

schema = json.loads(Path("invoices.schema.json").read_text(encoding="utf-8"))
invoices = from_table_schema(schema)
customers = read_rows(Path("customers.csv").read_text(encoding="utf-8"))

rows, errors = check(Path("invoices.csv").read_text(encoding="utf-8"), invoices, {"customers": customers})
print(f"{len(rows)} rows, {len(errors)} errors")
for error in errors:
    print(" ", error)

Output

5 rows, 7 errors
  row 3, customer_id: 'C-07' is not a customer_id of customers
  row 3, amount: 'abc' is not a decimal
  row 4, invoice_id: 'INV-003' is already the key of row 3
  row 4, paid: 'maybe' is not Y or N
  row 5, invoice_id: 'INV-05' does not match INV-[0-9]{3}
  row 5, customer_id: required, but empty
  row 5, amount: -20.00 is below the minimum 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tableschema_check.py

The report is the same seven errors, word for word, except for the name of the referenced table: a data package names its resources, customers, where CSVW names the file, customers.csv. The two vocabularies say the same things in different words:

What it says CSVW metadata Table Schema
A column an item of columns, with name and titles an item of fields, with name
What it holds datatype.base: string, decimal, date, boolean … type: string, number, date, boolean …
How a date is written format: "dd/MM/yyyy", Unicode date symbols format: "%d/%m/%Y", strptime codes
Digit grouping format: {"groupChar": ","} groupChar: ","
Words for true and false `format: “Y N”`
Allowed text a string’s format, an ECMAScript regular expression constraints.pattern, an XML Schema regular expression
Empty means missing null, "" by default missingValues, [""] by default
Keys primaryKey; foreignKeys with columnReference primaryKey; foreignKeys with fields

Write the description once

Keep one description as the source and generate anything else from it: a Table Schema from your CSVW metadata, or a JSON Schema for converted rows from either. Two descriptions written by hand drift apart, and then the readers do too.

Checkpoint

Check: Table Schema and its mapping to CSVW

  1. Question 1 of 2 The CSVW date format dd/MM/yyyy says the day comes first. What is the same format in the Table Schema?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "%d/%m/%Y"

    Both vocabularies can say day first; they use different notations. %d is the day, %m the month and %Y the year with four digits.

  2. Question 2 of 2 The CSVW column paid has the boolean format Y|N. How does the Table Schema say the same?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: trueValues ["Y"] and falseValues ["N"]

    Without them, a Table Schema boolean accepts its defaults, true, True, TRUE and 1 for true and their opposites for false, and Y would be an error.

A documented mapping turns typed rows into JSON

Once every cell has a type, converting a row to JSON is a matter of rules, and the third CSVW Recommendation, Generating JSON from Tabular Data on the Web, writes them down. Its minimal mode gives an array with one object per row, keyed by column name: a numeric cell becomes a JSON number, a boolean true or false, a date an ISO string, and a null cell gives no member at all. Its standard mode wraps the same objects with the table’s url and each row’s number. This program applies the minimal mode to the rows that passed:

The rows that passed, by the csv2json minimal mode Python · typed/to_json.py
"""The rows of invoices.csv that passed every check, as JSON in the minimal mode of the W3C's csv2json mapping."""

import json
from decimal import Decimal
from pathlib import Path

from typedcsv import check, from_csvw, read_rows

metadata = json.loads(Path("csv-metadata.json").read_text(encoding="utf-8"))
customers = read_rows(Path("customers.csv").read_text(encoding="utf-8"))
rows, errors = check(Path("invoices.csv").read_text(encoding="utf-8"), from_csvw(metadata, "invoices.csv"),
                     {"customers.csv": customers})
failed = {int(e.split(",")[0].removeprefix("row ")) for e in errors}


def json_value(value):
    """The documented mapping: a decimal becomes a JSON number, a boolean true or false, a date an ISO string."""
    if isinstance(value, Decimal):
        return float(value)
    if hasattr(value, "isoformat"):
        return value.isoformat()
    return value


# A null cell gives no member at all; the rows with errors are reported, not converted.
objects = [{k: json_value(v) for k, v in row.items() if v is not None}
           for n, row in enumerate(rows, start=1) if n not in failed]
print(json.dumps(objects, indent=2, ensure_ascii=False))
print(f"converted rows {sorted(set(range(1, len(rows) + 1)) - failed)}, left out rows {sorted(failed)}")

Output

[
  {
    "invoice_id": "INV-001",
    "customer_id": "C-01",
    "issued": "2026-10-04",
    "amount": 1250.5,
    "paid": true,
    "note": "first order"
  },
  {
    "invoice_id": "INV-002",
    "customer_id": "C-02",
    "issued": "2026-10-13",
    "amount": 99.0,
    "paid": false
  }
]
converted rows [1, 2], left out rows [3, 4, 5]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 to_json.py

Row 2 has no note member, because its note was empty and therefore null, and 99.00 became the JSON number 99.0. Leaving rows 3 to 5 out is this program’s choice, not the Recommendation’s: csv2json converts every row, and a cell that failed to parse keeps its text, as a string. Whichever you choose, say so in the description of the output, beside the mapping of each type.

The objects are typed JSON now, so a JSON Schema, as this track’s JSON Schema module writes them, can check them one at a time:

invoice.schema.json: one converted invoice JSON · typed/invoice.schema.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "One invoice, as csv2json writes it",
  "type": "object",
  "properties": {
    "invoice_id": { "type": "string", "pattern": "^INV-[0-9]{3}$" },
    "customer_id": { "type": "string" },
    "issued": { "type": "string", "format": "date" },
    "amount": { "type": "number", "minimum": 0 },
    "paid": { "type": "boolean" },
    "note": { "type": "string" }
  },
  "required": ["invoice_id", "customer_id", "issued", "amount"],
  "additionalProperties": false
}
JSON Schema Validator Paste either object and this schema to check it yourself, then change its amount to -20 and see the error the schema reports.

JSON Schema checks each object on its own, though, so it cannot see a repeated invoice_id or a customer missing from customers.csv: keys across rows and files need CSVW or Table Schema. Which description to publish depends on who reads the file. Answer four questions about a file of your own:

Interactive · answer and compare How should you describe a CSV file you publish? Answer four questions about your file. The table marks the descriptions that fit and says why the others do not.
Will programs outside your team read the file?
Do rows refer to rows of another file, as invoices refer to customers?
Must a reader find the description from the CSV's address alone?
Do the readers mostly want the rows as JSON?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to describe a CSV file, against what decides the choice
Description Programs can check it Keys across files Found from the CSV alone Defined JSON output
CSVW metadata Written as: csv-metadata.json, application/csvm+json Good types, formats, required, keys: 7 errors found in invoices.csv Good foreign keys between the tables of a group Good a Link header, then csv-metadata.json beside the file Good csv2json: one object per row, null cells left out
Table Schema Written as: a JSON descriptor, often inside a data package Good the same 7 errors from the same file Good foreign keys to other resources of a data package Mixed found through its data package, not from the CSV's URL Mixed typed values, but no W3C mapping to JSON
JSON Schema on rows Written as: a schema for each converted row Mixed checks each row once converted; the conversion needs rules too Poor one object at a time: no keys across files Poor nothing ties the schema to the CSV Good the rows are JSON by then
A README only Written as: prose for people Poor a person reads it; no program can Poor described in words, checked by nobody Mixed only if someone finds and reads it Poor every reader invents its own mapping
How the answers decide
  • “Will programs outside your team read the file?” Yes: rules out A README only. A README is for people; programs outside your team cannot check the file against it.
  • “Do rows refer to rows of another file, as invoices refer to customers?” Yes: rules out JSON Schema on rows and A README only. JSON Schema checks one object at a time, so the key between invoices and customers needs CSVW or Table Schema.
  • “Must a reader find the description from the CSV's address alone?” Yes: adds a caveat to Table Schema. A Table Schema is found through its data package; CSVW says where a processor looks from the CSV's own address.
  • “Must a reader find the description from the CSV's address alone?” Yes: rules out JSON Schema on rows. Nothing in a CSV file or its address leads to a JSON Schema for its rows.
  • “Do the readers mostly want the rows as JSON?” Yes: adds a caveat to Table Schema. Table Schema types the values but gives no standard JSON form; document the mapping, or use CSVW with csv2json.
  • “Will programs outside your team read the file?” No, only our own code: adds a caveat to A README only. Even inside a team, a description that code checks keeps the file honest; a README alone drifts.

Checkpoint

Check: From typed rows to JSON

  1. Question 1 of 2 Row 2 of the invoices has an empty note. What does the csv2json minimal mode write for it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No note member at all

    The output of to_json.py shows it: the first object has a note and the second has none. Write such rules into the description of your output, beside the mapping of each type.

  2. Question 2 of 2 The converted invoices are checked with invoice.schema.json. Which of the file's errors can that schema never catch?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Row 4's invoice_id INV-003, which repeats row 3's

    Keys across rows and files, the primary key and the foreign key to customers.csv, need CSVW or Table Schema. JSON Schema is a good second check for each converted object.

Practise: describe a school marks file with a Table Schema

The exercise gives you a validator for a small part of Table Schema and asks for the schema: a school’s marks file, with six-digit roll numbers, a list of subjects, marks from 0 to 100 and a key of two fields. The sample tests compare the validator’s report on bad files, line by line:

Exercise · Medium · Python

Describe a school marks file with a Table Schema

marks.py holds check(text, schema), a validator for a small part of Frictionless Table Schema: the types string and integer, the constraints required, pattern, enum, minimum and maximum, and primaryKey. Complete MARKS_SCHEMA, the Table Schema of a school's marks file, so that check() accepts every good file and reports every mistake. The file has the header roll,name,subject,marks, and:

  • roll is exactly six digits, such as 100245;
  • name must not be empty;
  • subject is one of maths, science, english and hindi;
  • marks is a whole number from 0 to 100;
  • a roll number has at most one row per subject: the key is the roll and the subject together, in that order.

Leave check() as it is: the sample tests run it with your schema and compare its report line by line.

Starter code · marks.py

import csv
import io
import re

MARKS_SCHEMA = {
    "fields": [
        {"name": "roll", "type": "string"},
        {"name": "name", "type": "string"},
        {"name": "subject", "type": "string"},
        {"name": "marks", "type": "string"},
    ],
    "primaryKey": [],
}


def cell(field, text):
    """The typed value of one cell, and what is wrong with it (None when nothing is)."""
    rules = field.get("constraints", {})
    if text == "":
        return None, "required, but empty" if rules.get("required") else None
    value = text
    if field.get("type") == "integer":
        if not re.fullmatch(r"-?\d+", text):
            return text, f"{text!r} is not an integer"
        value = int(text)
    if "pattern" in rules and not re.fullmatch(rules["pattern"], text):
        return value, f"{text!r} does not match {rules['pattern']}"
    if "enum" in rules and value not in rules["enum"]:
        return value, f"{text!r} is not one of {', '.join(map(str, rules['enum']))}"
    if "minimum" in rules and value < rules["minimum"]:
        return value, f"{value} is below the minimum {rules['minimum']}"
    if "maximum" in rules and value > rules["maximum"]:
        return value, f"{value} is above the maximum {rules['maximum']}"
    return value, None


def check(text, schema):
    """Every error of a CSV text against a Table Schema, as "row n, field: reason"; row 1 follows the header."""
    reader = csv.reader(io.StringIO(text, newline=""))
    names = [f["name"] for f in schema["fields"]]
    header = next(reader)
    errors = [] if header == names else [f"header {header} is not {names}"]
    seen = {}
    for n, values in enumerate(reader, start=1):
        row = {}
        for field, text_value in zip(schema["fields"], values):
            row[field["name"]], problem = cell(field, text_value)
            if problem:
                errors.append(f"row {n}, {field['name']}: {problem}")
        key = tuple(row.get(k) for k in schema["primaryKey"])
        if key and key in seen:
            errors.append(f"row {n}, {'+'.join(schema['primaryKey'])}: {key} is already the key of row {seen[key]}")
        seen.setdefault(key, n)
    return errors
The sample tests · test_marks.py
from marks import MARKS_SCHEMA, check

HEADER = "roll,name,subject,marks\r\n"


def marks_file(*lines):
    return HEADER + "".join(line + "\r\n" for line in lines)


def test_good_file():
    """accepts a correct file, with marks of 0 and 100"""
    text = marks_file("100245,Asha Iyer,maths,0", "100245,Asha Iyer,science,100", "100246,Ravi,hindi,72")
    assert check(text, MARKS_SCHEMA) == []


def test_roll_numbers():
    """refuses a roll number that is not six digits"""
    assert check(marks_file("12345,Meena,maths,64"), MARKS_SCHEMA) == ["row 1, roll: '12345' does not match [0-9]{6}"]


def test_subjects():
    """refuses a subject outside the list"""
    assert check(marks_file("100247,Kiran,art,80"), MARKS_SCHEMA) == [
        "row 1, subject: 'art' is not one of maths, science, english, hindi"
    ]


def test_marks_range_and_type():
    """refuses marks above 100, below 0 or not a whole number"""
    text = marks_file("100248,Joel,english,105", "100249,Radha,maths,-1", "100250,Zoya,maths,eighty")
    assert check(text, MARKS_SCHEMA) == [
        "row 1, marks: 105 is above the maximum 100",
        "row 2, marks: -1 is below the minimum 0",
        "row 3, marks: 'eighty' is not an integer",
    ]


def test_every_field_required():
    """refuses an empty value in any of the four fields"""
    assert check(marks_file("100251,,maths,", "100252,Vikram,science,55", ",Zoya,,40"), MARKS_SCHEMA) == [
        "row 1, name: required, but empty",
        "row 1, marks: required, but empty",
        "row 3, roll: required, but empty",
        "row 3, subject: required, but empty",
    ]


def test_one_row_per_subject():
    """refuses a second row for the same roll and subject"""
    text = marks_file("100245,Asha Iyer,maths,64", "100245,Asha Iyer,science,70", "100245,Asha Iyer,maths,68")
    assert check(text, MARKS_SCHEMA) == ["row 3, roll+subject: ('100245', 'maths') is already the key of row 1"]
A hint

Each field is a dict with a name, a type and, when it has rules, constraints, such as {"name": "marks", "type": "integer", "constraints": {"required": True, "minimum": 0, "maximum": 100}}. A pattern is matched against the whole value, so [0-9]{6} needs no anchors, and a key of two fields is a list of both names.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

CSV to JSON Converter Turn a CSV file into JSON with the header, typing and nesting options set by you, and compare the result with the documented mapping.

Then check yourself on CSVW metadata, validation, Table Schema and the JSON mapping. The questions adapt to your answers, so a concept you miss returns at an easier level, with the reason for every choice:

Practice that adapts to you

Practise this lesson

13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 What is the registered media type of a CSVW metadata file?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: application/csvm+json

    A server can label a metadata file application/csvm+json, and a Link header with rel="describedby" can point a processor from the CSV to it.

  2. Question 2 of 13 Why does csv-metadata.json describe customers.csv as well as invoices.csv?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Because the foreign key of the invoices refers to a table, and that table must be in the same group

    CSVW puts related tables in one table group, so a foreign key can name another table of the group, and a validator can check that every customer_id exists there.

  3. Question 3 of 13 A new version of the export writes its dates year first, with hyphens, as ISO 8601 does. What do you change in the metadata?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: The date format of issued, to yyyy-MM-dd, and nothing else

    The model requires implementations to understand yyyy-MM-dd, dd/MM/yyyy and M/d/yyyy among others. A change of form is a change of the format, recorded where every reader sees it.

  4. Question 4 of 13 What does the W3C model say a validator does when a cell breaks a rule?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It must raise an error, and it may go on validating

    csvw_check.py went on after each error and reported all seven, so the sender can fix the file in one round.

  5. Question 5 of 13 Row 5 has the amount -20.00 in a decimal column with minimum 0. What happens to that cell?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: It parses as a decimal, and the limit check reports it below the minimum

    Parsing and limits are two steps: first the text becomes a value, then the value is compared with the column's bounds. The figure showed the two steps on two different cells.

  6. Question 6 of 13 A partner uploads a file of 200,000 rows with 40,000 errors, almost all in one column. What makes your report most useful?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Every error with its row and column, plus a count per column and rule, so the one cause is visible at once

    Collect everything, as the model allows, then summarise. One wrong format in one column produces thousands of identical errors, and the summary turns them into one sentence.

  7. Question 7 of 13 Which texts does a Table Schema treat as missing values when it does not say otherwise?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Only the empty string, [""]

    Like CSVW's null property, missingValues says which texts mean "no value". A file that writes NA for missing values must list it, or NA becomes data.

  8. Question 8 of 13 The CSVW format of invoice_id is ^INV-[0-9]{3}$, the Table Schema pattern INV-[0-9]{3}. Why does the Table Schema need no ^ and $?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: An XML Schema regular expression always matches the whole value

    CSVW's format is an ECMAScript regular expression, so the lesson anchors it to cover the whole value; a Table Schema pattern covers the whole value already.

  9. Question 9 of 13 The Table Schema foreign key names the resource customers, the CSVW key the file customers.csv. What does that tell you about where a Table Schema lives?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: It is usually part of a data package, whose resources have names, and the package says which file each one is

    A Table Schema describes one table, and a data package groups tables as named resources, which is how its foreign keys find the table they refer to.

  10. Question 10 of 13 In the csv2json minimal mode, what does a decimal cell become?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: A JSON number

    Only the base of the datatype decides the JSON type: numbers become numbers, booleans true or false, and dates, times and the rest strings.

  11. Question 11 of 13 csv2json converts every row. What does it write for row 3's amount, abc, which failed to parse as a decimal?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: The text abc, as a string

    The model keeps a failed value as its text and records the error. Whether your own output keeps such rows, leaves them out or stops is a choice to document.

  12. Question 12 of 13 The JSON goes to a billing service that must add amounts exactly. 99.00 arrived as the number 99.0. What do you do?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Document a mapping that writes amounts exactly, such as whole paise as integers or the decimal as a string

    The JSON numbers lesson showed why money travels as whole minor units or as text. Write that choice into the description of your output, beside the mapping of each type.

  13. Question 13 of 13 You publish invoices.csv and customers.csv on the web for readers you do not know, and some of them want JSON. Which description fits best?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: CSVW metadata in csv-metadata.json beside the files

    The trade-off table's four questions decide it: outside readers rule out a README, keys between files rule out JSON Schema alone, and finding the description from the CSV's address and a defined JSON form both point to CSVW.

Interview questions

Warm-up (2–5 to 5–10 years): what do CSVW metadata and a Table Schema add to a CSV file?

They add what CSV itself cannot say: what each column holds and which values are allowed. RFC 4180 defines only how fields are separated and quoted, so every value is text, and every reader decides its type on its own. A CSVW metadata file, of the W3C’s CSV on the Web Recommendations, and a Frictionless Table Schema both sit beside the file as JSON and name each column’s type and format, such as a decimal with a comma between thousands or a date written dd/MM/yyyy, whether it may be empty, and the primary and foreign keys. A validator can then report every error by row and column, and every program reads the file the same way. CSVW also says where a processor finds the description from the CSV’s address, and how rows become JSON.

Two harder questions follow, about publishing a dataset that people keep misreading and about the validation step of a large upload. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Senior to staff

    Your team publishes a monthly CSV of school results that other organisations download. They keep misreading the marks and the dates, and two of them joined the file to the wrong schools. How would you describe the file so that every reader checks and converts it the same way?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is CSVW metadata, and how does a processor find it?

    A JSON file beside the CSV, found through a Link header or by a name such as csv-metadata.json. It gives each column a datatype and format, marks required cells and names the keys.

  2. What does a validator for typed CSV report?

    Every error with its row and column, after parsing each cell by its column and checking keys across rows and files, rather than stopping at the first one.

  3. How do CSVW and Table Schema write a day-first date format?

    CSVW with Unicode date symbols, dd/MM/yyyy; Table Schema with strptime codes, %d/%m/%Y.

  4. How does a Table Schema say which cells are missing?

    With missingValues, the list of texts that mean no value; by default only the empty string.

  5. What does csv2json's minimal mode produce?

    One JSON object per row with typed values, and null cells left out.

  6. What can CSVW or Table Schema check that JSON Schema on each row cannot?

    Keys across rows and files: a primary key that must be unique, and a foreign key that must point at an existing row.

Key takeaways

  • CSVW metadata is a JSON file beside the CSV, found by a Link header or by its name, csv-metadata.json and the like; it gives each column a datatype and format, says which cells are required, and names the primary and foreign keys.
  • A validator parses every cell by its column, checks the keys across rows and files, and reports every error with its row and column instead of stopping at the first.
  • Table Schema says the same in its own words, fields with types, formats and constraints, missingValues and keys, and the same engine found the same seven errors with it.
  • csv2json’s minimal mode turns typed rows into one JSON object per row, with null cells left out; JSON Schema can then check each object, but only CSVW or Table Schema can check keys across rows and files.

Terms in this lesson

CSVW metadata
A JSON file, of the media type application/csvm+json, that describes CSV files by the W3C's CSV on the Web Recommendations: their columns, the datatype and format of each, required cells, keys and the dialect.
foreign key
A column whose values must be the key of a row in another table, such as a customer_id that must exist in customers.csv.
primary key
The column, or the set of columns, whose values identify each row of a table: no two rows may have the same key.
Table Schema
The Frictionless Data specification, part of the Data Package standard, that describes a table as JSON: fields with types, formats and constraints, the texts that mean missing, and primary and foreign keys.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.