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.
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:
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
"""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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
{
"$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"] } }
]
} """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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
{
"$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 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
rollis exactly six digits, such as100245;namemust not be empty;subjectis one ofmaths,science,englishandhindi;marksis 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
What does csv2json's minimal mode produce?
One JSON object per row with typed values, and null cells left out.
-
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
Linkheader or by its name,csv-metadata.jsonand 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,
missingValuesand 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
- Model for Tabular Data and Metadata on the Web (W3C)
- Metadata Vocabulary for Tabular Data (W3C)
- Generating JSON from Tabular Data on the Web (W3C)
- Media type registration: application/csvm+json (IANA)
- Table Schema (Data Package standard, version 2) (Data Package Working Group)
- Media type registration: application/vnd.tableschema+json (IANA)
- JSON Schema: a Media Type for Describing JSON Documents (draft 2020-12) (JSON Schema)
- RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress