Skip to content
datahacks.dev
CSV

Validating CSV Columns With Python and pydantic Models

Validate each CSV row against a pydantic model with types, literals, patterns and a cross-field rule: 193,708 rows in 0.95 s, with file line numbers.

10 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the Python logo.

A schema file describes columns one at a time. Sometimes you want the rules to read like code: this field is an integer above zero, this one is one of three words, and if the currency is filled in the balance must be too. pydantic is a Python library for validating data against type annotations, and it fits CSV well, since every row is a small record with named fields. This article builds a model for the customer export, validates 193,708 rows against it, and spends most of its time on what pydantic's type conversion accepts, which turned out to be more generous than a validator should be. It is one of the tools in the validation guide.

The model

One class describes one row. The annotations are the schema, and the validators are the rules:

t0125.pyPython
import collections, csv, sys, time
from datetime import date
from decimal import Decimal
from typing import Annotated, Literal, Optional

from pydantic import BaseModel, BeforeValidator, ConfigDict, StringConstraints, ValidationError, model_validator


def blank_to_none(v):
    return None if v == "" else v


Blankable = lambda t: Annotated[Optional[t], BeforeValidator(blank_to_none)]


class Customer(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: int
    first_name: str
    last_name: str
    email: Annotated[str, StringConstraints(pattern=r"[^@\s]+@[^@\s]+\.[^@\s]+")]
    phone: Annotated[str, StringConstraints(pattern=r"|\+1\d{10}")]
    phone_unparsed: str
    signup_date: Blankable(date)
    balance: Blankable(Decimal)
    balance_currency: Literal["", "USD", "EUR"]
    units: Blankable(int)
    country: Literal["", "US", "IT", "DE", "BR"]
    status: Literal["active", "pending", "inactive"]

    @model_validator(mode="after")
    def currency_needs_balance(self):
        if self.balance_currency and self.balance is None:
            raise ValueError("balance_currency is set but balance is empty")
        return self

A few choices in there carry the weight.

**Literal for closed lists.** status is one of three words and country one of four or empty. A typo is a validation error, reported with the allowed values. See the pydantic docs on validators for the other tools.

**StringConstraints(pattern=...) for shapes.** The email and phone fields keep their text and require a pattern. The phone is a string, not an integer, because an international number is an identifier, not a quantity.

**Blankable for optional columns.** A CSV has no null, only empty text, and pydantic treats '' as an invalid integer, date or decimal. The BeforeValidator turns an empty string into None before the type check, so an empty units is allowed and 12x is not. Without it, every empty cell is an error.

**extra="forbid".** A row with more fields than the header gets an error naming the extra values, instead of having them silently ignored.

**model_validator.** The rule that a currency without a balance is an error compares two fields, which is the one thing a per-column schema cannot do.

Getting rows into it

The model validates a dictionary. This generator reads the file with the csv module and builds one, with the file line number:

t0125.pyPython
def records(path):
    with open(path, newline="", encoding="utf-8") as f:
        r = csv.reader(f)
        header = next(r)
        for n, row in enumerate(r, start=2):
            d = dict(zip(header, row))
            if len(row) > len(header):
                d["extra_values"] = "|".join(row[len(header):])
            yield n, d

dict(zip(header, row)) is deliberate. A short row produces a dictionary with fewer keys, and pydantic reports each missing key as missing. A row with extra fields would be truncated by zip, so the surplus goes into an extra_values key that the model forbids. Using csv.DictReader instead would hide both cases: it fills missing values with None and collects extras under a key you have to know about. Building the dictionary yourself keeps the structure errors visible. The line number is enumerate(..., start=2): line 1 is the header. This is a record count, so it matches the editor's line only until the first quoted newline, as the counting article explains.

The validation call is Customer.model_validate_strings(d). It is designed for input whose values are all strings, which is what a CSV gives you. For these inputs it returned the same results as the ordinary model_validate.

What the parser accepts

This is where a validator can go wrong without a sound. Pydantic's job is to convert, and conversion is generous. I fed small models strings and recorded the result:

Shell
python /batches/csv-0001-0200/lab/val/t0125b.py
Text
  I  '12'                       12
  I  ' 12 '                     12
  I  '1_000'                    1000
  I  '1e3'                      ERROR int_parsing
  I  '12.0'                     12
  I  '12.5'                     ERROR int_parsing
  I  '0x10'                     ERROR int_parsing
  I  ''                         ERROR int_parsing
  I  '٣'                        ERROR int_parsing
  I  '+7'                       7
  I  '-0'                       0
  B  'true'                     True
  B  'T'                        True
  B  'yes'                      True
  B  'on'                       True
  B  '1'                        True
  B  'y'                        True
  B  '2'                        ERROR bool_parsing
  B  ''                         ERROR bool_parsing
  D  '2025-03-14'               datetime.date(2025, 3, 14)
  D  '2025-03-14T10:00:00'      ERROR date_from_datetime_inexact
  D  '2025-02-30'               ERROR date_from_datetime_parsing
  D  '03/14/2025'               ERROR date_from_datetime_parsing
  D  '1741910400'               datetime.date(2025, 3, 14)
  D  ''                         ERROR date_from_datetime_parsing
  N  '1,200'                    ERROR decimal_parsing
  N  '1200.50'                  Decimal('1200.50')
  N  '1e3'                      Decimal('1E+3')
  N  'NaN'                      ERROR finite_number
  N  ''                         ERROR decimal_parsing
  N  '$5'                       ERROR decimal_parsing

For integers: 12, 12 (with spaces), 12.0, +7 and -0 were accepted, and so was 1_000, which became 1000. 1e3, 12.5, 0x10, the empty string and an Arabic-Indic digit were rejected. For booleans, true, T, yes, on, 1 and y all became True, and 2 and the empty string failed. For dates, 2025-03-14 parsed, 2025-02-30 and 03/14/2025 were rejected, and 2025-03-14T10:00:00 was rejected as a date with a time. But the string 1741910400 became the date 2025-03-14, because pydantic treats a number as seconds since the Unix epoch. For decimals, 1200.50 and 1e3 parsed, and 1,200, NaN, the empty string and $5 did not.

The surprising ones are the useful lesson. A ten digit order number in a date column would validate as a date. A field containing 1_000 would be accepted as a thousand where the file's author probably meant a typo. Whether that matters depends on the data. When it does, add a pattern before the conversion so the text has to look the way you expect: a BeforeValidator that rejects anything not matching \d{4}-\d{2}-\d{2} for a date, for instance. A type annotation is a conversion rule, and a validator should be stricter than the conversion it performs.

Running it

The script collects errors instead of stopping at the first, keeps the file line, and counts the error types. The good file:

Shell
python /batches/csv-0001-0200/lab/val/t0125.py val/customers.csv
Text
193708 rows, 0 errors, 1.07s
by type: {}

193,708 rows, no errors, 0.95 s. The faulty copy:

Shell
python /batches/csv-0001-0200/lab/val/t0125.py val/customers-bad.csv
Text
193708 rows, 9 errors, 1.00s
  line 102  email              string_pattern_mismatch input='not-an-email'
  line 202  status             literal_error          input='actve'
  line 302  signup_date        date_from_datetime_parsing input='2025-13-45'
  line 402  id                 duplicate              input='first seen on line 401'
  line 502  extra_values       extra_forbidden        input='extra'
  line 602  country            missing                input=''
  line 602  status             missing                input=''
  line 702  country            literal_error          input='Atlantis'
  line 802  units              int_parsing            input='12x'
by type: {'string_pattern_mismatch': 1, 'literal_error': 2, 'date_from_datetime_parsing': 1, 'duplicate': 1, 'extra_forbidden': 1, 'missing': 2, 'int_parsing': 1}

Nine errors for eight faults, each with its line:

  • 102: string_pattern_mismatch on email

  • 202: literal_error on status (actve)

  • 302: date_from_datetime_parsing on signup_date (2025-13-45)

  • 402: duplicate, first seen on line 401

  • 502: extra_forbidden on extra_values

  • 602: missing for country and for status, which is one fault and two errors

  • 702: literal_error on country (Atlantis)

  • 802: int_parsing on units (12x)

The duplicate on line 402 is not pydantic's. A model validates one row at a time, so it cannot know that an id has appeared before. The script keeps a dictionary from each id to the first line it appeared on, and adds a duplicate error when it meets one again. This is a pattern worth remembering: the model covers the row, and a few lines of ordinary Python cover everything that spans rows (uniqueness, ordering, the count). The duplicate keys article goes deeper on the second kind.

A rule across fields

The cross-field validator is a rule that only code can state. A balance currency of USD with an empty balance:

Shell
cd /batches/csv-0001-0200/lab/val && python -c "
from t0125 import Customer
from pydantic import ValidationError
row = dict(id='7', first_name='Ana', last_name='Silva', email='[email protected]', phone='', phone_unparsed='', signup_date='2025-03-01', balance='', balance_currency='USD', units='', country='US', status='active')
try:
    Customer.model_validate_strings(row)
except ValidationError as e:
    err = e.errors()[0]
    print(err['type'], '|', err['msg'], '| loc', err['loc'])
row['balance_currency'] = ''
print(Customer.model_validate_strings(row).balance_currency == '', 'accepted once the currency is empty too')
"
Text
value_error | Value error, balance_currency is set but balance is empty | loc ()
True accepted once the currency is empty too

The model raised value_error with the message the validator wrote, and accepted the row once the currency was empty too. The error's location is empty, because the rule belongs to the whole row, not a field. Rules like this (an end date after a start date, a refund no larger than the order, a required field when another has a value) are the best reason to use a model instead of a schema file. In the cleaned customer file the rule never fired, which is the result you want from a check on data you cleaned yourself and need on data you did not.

Speed and its limits

The faulty file took 0.92 s and the good one 0.95 s for 193,708 rows. That is fast enough that I would not think about it for any file that fits in memory, and it was nearly six times faster than Frictionless on the same file (5.5 s). The comparison is not entirely fair, because the two check slightly different things, but it says a model is not the slow option. For files of tens of millions of rows, validate in chunks, or validate a sample and run the cheap structure checks on everything.

Choosing between a model and a schema

A schema file has one advantage: the provider can read it, and so can people who do not write Python. A model can express rules a schema cannot, can share types with the rest of your application, and gives you precise control over conversion. In practice I keep the data contract as a document and a schema, and use a model when the rules are code-shaped, or when the same class will validate rows from the CSV and from an API. They are not exclusive. The contract states what is promised, and whichever tool enforces it should be easy to read next to that statement.

How to tell it worked

Validate a file you know is clean and expect no errors. Plant one fault per rule and expect exactly one error per fault, at the right line. Pay particular attention to the coercion cases for your own types: try a number in a date column, 1_000 in an integer, yes in a boolean, and see what your model does. Write each of them down as a test. And print the failures with the offending text: a message that says "int_parsing" without 12x makes someone open the file.

What a row model cannot see

It sees one row. It cannot tell that the file is missing its last 400 rows, that a column has changed meaning, that a key appears twice or that today's average balance is a tenth of yesterday's. It does not see the bytes either, so a truncated last line fails as a missing field only if the cut falls inside a row, and a file cut exactly between two rows validates perfectly. Run the row and column counts and a checksum before this step, and the checks across rows after it.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.