csv DictReader/DictWriter Ragged Rows: the None Key (tested)

A CSV row with fewer or more fields than the header doesn't raise an error in csv.DictReader — it silently fills gaps with None or stashes extras under a dict key that is the literal None object, not the string "None". We measured exactly what each side of csv does with ragged data, and found the read and write sides don't even agree on whether a mismatch should raise.

1. A short row: missing values become None, not empty string

import csv, io

f = io.StringIO("a,b,c\n1,2\n")
list(csv.DictReader(f))
[{\'a\': \'1\', \'b\': \'2\', \'c\': None}]

Measured: a row with one fewer field than the header raises nothing — the missing column's value is the Python object None, by default. This is easy to miss if code later does row["c"].strip() expecting a string like csv normally returns for every other field; a ragged row is the one case where a field can be a non-string None.

2. A long row: extras nest under a literal None key, as a list

f = io.StringIO("a,b,c\n1,2,3,4,5\n")
list(csv.DictReader(f))
[{\'a\': \'1\', \'b\': \'2\', \'c\': \'3\', None: [\'4\', \'5\']}]

Measured: extra fields beyond the header are collected into a list and stored under the dict key None — the actual None object, not the string "None". row.get("None") returns nothing; only row.get(None) or row[None] finds it. This also means list(row.keys()) contains a non-string entry, which is easy to miss when iterating keys and assuming they're all column names.

3. That None key still serializes through json.dumps() without error

import json
json.dumps({"a": "1", "b": "2", "c": "3", None: ["4", "5"]})
\'{"a": "1", "b": "2", "c": "3", "null": ["4", "5"]}\'

Measured: json.dumps() silently coerces the None key to the string "null" — the same int/float/bool/None key coercion covered in our json serialization guide. So a ragged CSV row piped straight into json.dumps() produces a JSON object with a real "null" key holding the overflow fields, with no error anywhere in the pipeline to flag that the source row was malformed.

4. You can replace both defaults with restkey and restval

csv.DictReader(f, restkey="EXTRA", restval="MISSING")
# short row -> {\'a\': \'1\', \'b\': \'2\', \'c\': \'MISSING\'}
# long row  -> {\'a\': \'1\', \'b\': \'2\', \'c\': \'3\', \'EXTRA\': [\'4\', \'5\']}

Measured: setting restkey to any string replaces the None key with that string, and restval replaces the None fill value for short rows. Neither is required, and both default to values (None, None) that are easy to overlook since they never raise.

5. DictWriter disagrees: an extra key raises by default

w = csv.DictWriter(f, fieldnames=["a", "b"])
w.writerow({"a": 1, "b": 2, "c": 3})
ValueError: dict contains fields not in fieldnames: \'c\'

w2 = csv.DictWriter(f, fieldnames=["a", "b", "c"])
w2.writerow({"a": 1, "b": 2})   # missing \'c\'
# writes "1,2," -- no error, empty string used

Measured: the write side is asymmetric with the read side and with itself: an unexpected extra key raises ValueError immediately, while a missing key raises nothing at all and silently writes an empty string. Pass extrasaction="ignore" to silently drop extra keys instead of raising, or restval=... to control what a missing key writes instead of the default empty string.

6. What to actually check

When reading, check for a None key or a None value in each row before passing it downstream — both indicate the source row didn't match the header's column count, and neither raises on its own. When writing, decide and set extrasaction explicitly ("raise" is the default, "ignore" silently drops) rather than relying on whichever happens to match your data today, since adding one new key to the dicts you're writing later will suddenly start raising on every row.

Frequently asked questions

What happens if a CSV row has fewer fields than the header in DictReader?

We measured that csv.DictReader fills the missing column(s) with the Python value None by default (not an empty string) — no exception is raised. Pass restval=\"something\" to change that fill value.

What happens if a CSV row has more fields than the header in DictReader?

We measured that the extra fields are collected into a list and stored under the dict key None (the literal None object, not the string \"None\") — again with no exception. Pass restkey=\"something\" to use a named key instead.

Does json.dumps() handle a dict with a None key from DictReader?

Yes — we confirmed json.dumps() silently converts the None key to the string \"null\" with no error, the same coercion behavior that applies to int/float/bool/None dict keys generally in Python's json module.

Does csv.DictWriter raise an error for ragged dict rows the same way DictReader doesn't?

No — we measured the opposite: DictWriter raises ValueError immediately if a row dict has a key not in fieldnames, but silently writes an empty string with no error at all if a row dict is missing a key that is in fieldnames.

How do I stop DictWriter from raising on extra dict keys?

We tested extrasaction=\"ignore\" on csv.DictWriter, which silently drops any keys not in fieldnames instead of raising ValueError: dict contains fields not in fieldnames.

Written with AI assistance. Measured on 2026-10-03 with Python 3.13.5's built-in csv module: fed csv.DictReader() rows with fewer and more fields than the header, with and without restkey/restval set, and fed csv.DictWriter() dicts with extra and missing keys, with and without extrasaction/restval set, recording the exact dict/row output and exceptions. PlainHeaders is our extension; Python's csv module is built in.