Skip to content
datahacks.dev
CSV

Reading a CSV Into Dictionaries With csv DictReader

csv.DictReader turns each CSV row into a dict keyed by the header. See what it does with missing and extra fields, BOMs, duplicate headers and blank lines.

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

If you have written row[3] more than once in a CSV script, you know the cost: the code works until someone adds a column and then it returns the wrong thing without complaint. csv.DictReader fixes that by reading the header and giving each record as a dictionary keyed by column name, so row["amount"] keeps working when the columns are reordered. It also has more places to go quietly wrong than the plain reader does, and this article lists them, with the output of each. The reader underneath, and the file handling it needs, are in the csv module article, and the full picture is in the guide to CSV in Python.

The basic loop

Using the same file as before, with a quoted comma, a doubled quote and a line break inside a field:

dict_basic.pyPython
import csv

with open("people.csv", newline="", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    print(reader.fieldnames)
    for row in reader:
        print(reader.line_num, dict(row))
Shell
python /batches/csv-0001-0200/lab/py/dict_basic.py
Text
['id', 'name', 'city', 'note']
2 {'id': '1', 'name': 'Ana Silva', 'city': 'Lisbon', 'note': 'likes tea'}
3 {'id': '2', 'name': 'Smith, Jane', 'city': 'Boston', 'note': 'said "hello"'}
5 {'id': '3', 'name': 'Bo Chen', 'city': 'Oslo', 'note': 'line one\nline two'}
6 {'id': '4', 'name': 'Cy Dubois', 'city': 'Lyon', 'note': ''}

reader.fieldnames is the header as a list, read when you first ask for it or iterate. Each record is a dictionary from header name to string. Quoting is resolved exactly as in the plain reader: Smith, Jane is one value, and the note with the line break has \n in it. line_num again counts physical lines, not records, which is why it goes 2, 3, 5, 6. I wrapped each row in dict() only for printing; the rows are already dictionaries in current Python versions, and keep the order of the columns in the file.

Every value is a string, including id. Conversion is yours to write, and it is better written in one function than scattered:

dict_types.pyPython
import csv
from datetime import date

with open("dates-iso.csv", newline="", encoding="utf-8") as f:
    rows = [
        {"id": int(r["id"]), "joined": date.fromisoformat(r["joined"]), "amount": float(r["amount"])}
        for r in csv.DictReader(f)
    ]
print(rows[0], type(rows[0]["joined"]).__name__)
print(sum(r["amount"] for r in rows))
Shell
python /batches/csv-0001-0200/lab/py/dict_types.py
Text
{'id': 1, 'joined': datetime.date(2025, 3, 14), 'amount': 10.0} date
60.0

The id became an int, joined a datetime.date through date.fromisoformat and amount a float. The sum is 60.0. For money, replace float with decimal.Decimal, since floats cannot hold most decimal fractions exactly. A conversion that fails raises ValueError with the offending text; catch it where you can add the line number from reader.line_num so the message points at the row.

Short rows and long rows

A CSV does not guarantee that every row has the header's width. DictReader has a rule for each direction, and the defaults can surprise you. This file has a short record (2,Bo) and a long one (3,Cy,5,extra):

ragged.csvcsv
id,name,qty
1,Ana,3
2,Bo
3,Cy,5,extra
4,Di,7
dict_ragged.pyPython
import csv

with open("ragged.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f, restkey="_extra", restval="MISSING"):
        print(dict(row))
print("--- defaults")
with open("ragged.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(dict(row))
Shell
python /batches/csv-0001-0200/lab/py/dict_ragged.py
Text
{'id': '1', 'name': 'Ana', 'qty': '3'}
{'id': '2', 'name': 'Bo', 'qty': 'MISSING'}
{'id': '3', 'name': 'Cy', 'qty': '5', '_extra': ['extra']}
{'id': '4', 'name': 'Di', 'qty': '7'}
--- defaults
{'id': '1', 'name': 'Ana', 'qty': '3'}
{'id': '2', 'name': 'Bo', 'qty': None}
{'id': '3', 'name': 'Cy', 'qty': '5', None: ['extra']}
{'id': '4', 'name': 'Di', 'qty': '7'}

The second half of the output, labeled "defaults", is what you get with no options. The short record is {'id': '2', 'name': 'Bo', 'qty': None}: the missing field is None, not an empty string. The long record has its extra value in a list under the key None: {'id': '3', 'name': 'Cy', 'qty': '5', None: ['extra']}. A dictionary with a None key is easy to miss and will crash code that sorts the keys or writes the dict back out.

restval sets the filler for missing fields, and restkey names the key under which extra values collect. With restval="MISSING" and restkey="_extra" the output is 'qty': 'MISSING' and '_extra': ['extra']. Choose values that cannot be mistaken for data. Better, test for them and stop: a record that has the wrong number of fields is almost always a symptom of a quoting problem earlier in the file, and a later column that is quietly shifted left is worse than a crash. The inconsistent column counts article goes into finding the real cause.

Traps in the header

The header is parsed with the same rules as any row, which is the source of four quiet problems. I tested each on a small file:

dict_header_traps.pyPython
import csv

def first_row(name, **kw):
    with open(name, newline="", encoding=kw.pop("encoding", "utf-8")) as f:
        r = csv.DictReader(f, **kw)
        return r.fieldnames, dict(next(r))

print("bom, utf-8     :", first_row("bom.csv"))
print("bom, utf-8-sig :", first_row("bom.csv", encoding="utf-8-sig"))
print("dup header     :", first_row("dup-header.csv"))
print("spaced header  :", first_row("spaced.csv"))
print("spaced, skip   :", first_row("spaced.csv", skipinitialspace=True))
with open("blank-lines.csv", newline="", encoding="utf-8") as f:
    print("blank lines    :", [dict(r) for r in csv.DictReader(f)])
with open("blank-lines.csv", newline="", encoding="utf-8") as f:
    print("reader blanks  :", list(csv.reader(f)))
Shell
python /batches/csv-0001-0200/lab/py/dict_header_traps.py
Text
bom, utf-8     : (['\ufeffid', 'name'], {'\ufeffid': '1', 'name': 'Ana'})
bom, utf-8-sig : (['id', 'name'], {'id': '1', 'name': 'Ana'})
dup header     : (['id', 'name', 'name'], {'id': '1', 'name': 'Silva'})
spaced header  : (['id', ' name ', ' city'], {'id': '1', ' name ': ' Ana ', ' city': ' Lisbon'})
spaced, skip   : (['id', 'name ', 'city'], {'id': '1', 'name ': 'Ana ', 'city': 'Lisbon'})
blank lines    : [{'id': '1', 'name': 'Ana'}, {'id': '2', 'name': 'Bo'}, {'id': '3', 'name': 'Cy'}]
reader blanks  : [['id', 'name'], ['1', 'Ana'], [], ['2', 'Bo'], [], [], ['3', 'Cy']]

A byte order mark. A file saved by Excel or another Windows tool often begins with the three bytes EF BB BF. Opened as utf-8, the BOM is decoded as the character  and becomes part of the first header: the key is 'id', and row["id"] raises KeyError, though the file looks right in every editor. Opened with encoding="utf-8-sig" the BOM is removed and the key is 'id'. The utf-8-sig codec is described on the codecs page. Using utf-8-sig for reading is harmless on files that have no BOM, so I use it by default for input from people. The BOM article explains where the bytes come from.

A duplicate header. With id,name,name the field names are kept as given, both name entries, but a dictionary has one slot for each key. The row came out as 'name': 'Silva', the value from the second column; the first column's 'Ana' is gone, and no warning was raised. If a file might have duplicate headers, compare len(set(fieldnames)) with len(fieldnames) and fail on a difference.

Spaces in the header. With id, name , city, the keys are ' name ' and ' city', spaces included, and row["name"] raises KeyError. skipinitialspace=True removes the space after each delimiter, which fixes ' city' and leaves 'name ' with its trailing space. The reliable fix is to clean the names yourself: reader.fieldnames = [h.strip() for h in reader.fieldnames] before the loop.

Blank lines. DictReader skips them. The plain reader does not: the same file read with csv.reader returned an empty list for each blank line ([]), three of them, while DictReader gave the three real records. Skipping is convenient, and it is a way for a stray empty line in the middle of a file to disappear. If an empty line should be an error in your data, use the plain reader and test for it.

A file with no header

If the file has no header, fieldnames supplies the names. If it has a header you want to replace, drop the first line before handing the file to DictReader:

dict_noheader.pyPython
import csv

with open("zips.csv", newline="", encoding="utf-8") as f:
    next(f)  # drop the header line
    for row in csv.DictReader(f, fieldnames=["ident", "zip_code", "st"]):
        print(dict(row))
        break
Shell
python /batches/csv-0001-0200/lab/py/dict_noheader.py
Text
{'ident': '1', 'zip_code': '02134', 'st': 'MA'}

next(f) consumed the header line, fieldnames supplied the three new names, and the first record came out as {'ident': '1', 'zip_code': '02134', 'st': 'MA'}. Without the next(f), the original header would have been returned as a data row with the new names, a mistake that is easy to make and easy to see once you print one row. Note that 02134 kept its leading zero, since nothing converted it; that is one of the advantages of reading strings and converting on purpose, a point the pandas ZIP code article takes up from the other side.

How to tell it worked

Print reader.fieldnames and read it, with repr so that invisible characters show. Check that len(set(fieldnames)) == len(fieldnames). Count the rows and compare with a count from another tool. For each record, assert that None is not a key and that no value is None, which catches the short and long record cases in one test. Finally, convert a few values and look at the types you got.

When DictReader is the wrong shape

A dictionary per row costs memory and time. Holding the first 300,000 rows of the orders file as plain lists took 151 MB by tracemalloc, and as dictionaries 197 MB, about 1.3 times as much; summing one column of all 2,000,000 rows took 1.52 s with csv.reader and about 3.1 s with DictReader, at the same 13 MB of peak memory because both stream. If you are streaming and keep nothing, it does not matter. If you are collecting millions of rows, use the plain reader with a lookup table of column positions built once from the header (pos = {h: i for i, h in enumerate(header)}), or load the file with pandas or DuckDB and work with columns instead of rows. If the columns you need are at known positions in a file with no reliable header, the plain reader is simpler. And if the work is mostly filtering and aggregating, a SQL query on the file removes the loop altogether.

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.