How-to › Parse, validate and transform data

How to load a CSV into a DataFrame with the column types you want#

Declare each column's type in pandas, Polars, or DuckDB, so ZIP codes and ids keep their zeros and a renamed or dropped column stops the load.

Audience
Data analyst
Level
beginner
Topic
Parse config and data formats
Languages
Python
Verified

Your customer export loads without an error, but the DataFrame is wrong. ZIP code 02134 comes back as 2134, customer 007731 as the integer 7731, and a visits column with a few empty cells as floats. Namibia’s country code, NA, turns into a missing value. The same table exported a day earlier gave some of those columns different types.

What you get

You will end up with one declared schema per library, pandas, Polars, and DuckDB, that loads every export with the same types and raises when a column is renamed or dropped. This is for you if a CSV export feeds an analysis you rerun.

Short answer

Declare every column’s type when pandas, Polars, or DuckDB reads a CSV, so ids keep their leading zeros and every export loads with the same types. In pandas, pass dtype with str and Int64, usecols, and keep_default_na=False with na_values=['']. In Polars, give scan_csv a full schema. In DuckDB, declare types by name in read_csv, because columns applies by position.

You will need

Python 3.12 or later and a CSV export with mixed column types. Verified 2026-10-21 against Python 3.13.16, pandas 3.0.6, Polars 2.0.0, and DuckDB 1.5.6. The sample installs the three libraries from a pinned requirements.txt into vendor/. It reads two exports of one customer table, monday.csv and tuesday.csv, plus six copies changed the way upstream exports change. It reads files only; pulling records from an API into a notebook is a separate task.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
DuckDB read_csvYou want SQL over the file before any of it reaches a DataFrameIts columns option applies names by position, and .df() picks int64 or Int64 by whether NULLs appearThe pipeline is pandas or Polars from end to end and needs no SQL
pandas read_csvThe analysis already lives in pandas and the file fits in memoryDeclaring takes a dtype map, usecols, two NA settings, and a parse for each date, and each one you omit falls back to guessingExports arrive often and every change in shape must stop the load
Polars scan_csvYou want one schema object, a lazy scan, and an error for any column it does not nameAn added upstream column stops every load until you add it to the schemaYour consumers need pandas objects and you want no conversion step

pandas takes the most settings to pin down, and anything you leave out goes back to being guessed. Polars needs one schema and refuses whatever it does not describe, so an extra column upstream becomes an outage you have to fix. DuckDB declares types in SQL and samples far more rows before it guesses, but its columns option applies names by position.

See what each reader guesses

CSV has no types for a reader to find, only text between commas, so each library samples rows and guesses.1 pandas decides per read, or per chunk when you read in chunks. Polars looks at the first 100 rows, and DuckDB at a sample of 20,480.2 infer.py asks all three about two exports of the same table, a day apart.

PYTHONPATH=vendor python3 infer.py
inferred                  customer_id  zip          visits
pandas  monday.csv        int64        int64        int64
pandas  tuesday.csv       int64        str          float64
polars  monday.csv        Int64        Int64        Int64
polars  tuesday.csv       Int64        String       Int64
duckdb  monday.csv        BIGINT       VARCHAR      BIGINT
duckdb  tuesday.csv       VARCHAR      VARCHAR      BIGINT

what pandas made of the values
  monday.csv   zip          [10001, 2134, 60601]
  tuesday.csv  customer_id  [120048, 7731, 120050]
  tuesday.csv  country      ['US', 'CA', nan]
  tuesday.csv  visits       [nan, 2.0, nan]

Every library gives at least one column a different type on Tuesday. In pandas and Polars, a Canadian postal code turns the ZIP column into text. In DuckDB, one zero-padded id turns customer_id into VARCHAR. The values are worse than the types. pandas drops the zero from 02134, reads 007731 as 7731, reads Namibia’s NA as missing, and makes visits a float column because two cells are empty.

Declare the columns in each library

A declared type replaces the guess. Each loader in load.py reads both exports the same way, whatever rows they hold.

# pandas: dtype names each column's type, and usecols makes a missing column an error.
PANDAS_DTYPES = {
    'customer_id': 'str',
    'zip': 'str',
    'country': 'str',
    'signup': 'str',  # parsed below, with one format
    'visits': 'Int64',  # nullable, so an empty cell leaves it an integer column
    'amount': 'float64',
}


def with_pandas(path):
    frame = pd.read_csv(
        path,
        usecols=[*PANDAS_DTYPES],
        dtype=PANDAS_DTYPES,
        keep_default_na=False,  # 'NA' is Namibia, not a missing value
        na_values=[''],  # only an empty field is missing
    )
    # parse_dates would guess the format, and keep a date it cannot parse as text.
    # to_datetime with a format raises on any other spelling.
    frame['signup'] = pd.to_datetime(frame['signup'], format='%Y-%m-%d')
    return frame

pandas needs three arguments beyond dtype. usecols names the expected columns, so a renamed or missing one raises. keep_default_na=False with na_values=[''] leaves the empty field as the only missing value,3 and Int64 is the pandas integer type that can hold one. The date is read as text and parsed with one format, because parse_dates guesses the format, and the read_csv reference says a date column it cannot parse comes back unaltered, without an error.

def with_polars(path):
    text_date = {**POLARS_SCHEMA, 'signup': pl.String}
    return (
        pl.scan_csv(path, schema=text_date)
        .with_columns(pl.col('signup').str.to_date('%Y-%m-%d'))
        .collect()
    )

POLARS_SCHEMA maps the same six columns to pl.String, pl.Date, pl.Int64, and pl.Float64. A full schema switches inference off, and scan_csv builds a lazy query that reads the file when collect() runs, so a filter placed before it reads less. The date arrives as text and str.to_date parses it with one format, which raises on any other.

# DuckDB: a types struct is matched by column name, the SELECT names what to keep, and
# dateformat replaces the guess at how a date is written.
DUCKDB_QUERY = """
SELECT customer_id, zip, country, signup, visits, amount
FROM read_csv($path, dateformat = '%Y-%m-%d', types = {
    'customer_id': 'VARCHAR', 'zip': 'VARCHAR', 'country': 'VARCHAR',
    'signup': 'DATE', 'visits': 'BIGINT', 'amount': 'DOUBLE'
})
"""


def with_duckdb(path):
    frame = duckdb.sql(DUCKDB_QUERY, params={'path': str(path)}).df()
    # .df() turns a BIGINT column into int64 when it has no NULLs and Int64 when it has some.
    return frame.astype({'visits': 'Int64'})

DuckDB’s CSV reference describes types as a struct by name or a list by position, and the struct is the safe form. A declared DATE still guesses how the date is written, so dateformat names it. The cast at the end matters: without it, Monday’s visits arrived in pandas as int64 and Tuesday’s as Int64.

PYTHONPATH=vendor python3 declared.py
declared                  customer_id  zip          visits
pandas  monday.csv        str          str          Int64
pandas  tuesday.csv       str          str          Int64
polars  monday.csv        String       String       Int64
polars  tuesday.csv       String       String       Int64
duckdb  monday.csv        str          str          Int64
duckdb  tuesday.csv       str          str          Int64

what with_pandas made of the values
  monday.csv   zip          ['10001', '02134', '60601']
  tuesday.csv  customer_id  ['120048', '007731', '120050']
  tuesday.csv  country      ['US', 'CA', 'NA']
  tuesday.csv  visits       [<NA>, 2, <NA>]

Both exports now load with the same types in every library. The zeros and Namibia survive, and the empty visits are <NA> in an integer column instead of nan in a float one.

Make a changed export fail

An export that changes shape should stop the load, not slip through it. changes.py feeds six altered copies of Monday’s first customer to each declared loader, and to a DuckDB loader that uses columns instead of types.

PYTHONPATH=vendor python3 changes.py
change             pandas          polars          duckdb types    duckdb columns
renamed column     raises          raises          raises          loads
reordered columns  loads           loads           loads           loads, swapped
added column       loads           raises          loads           raises
dropped column     raises          raises          raises          raises
bad value          raises          raises          raises          raises
date format        raises          raises          raises          loads, wrong date

The three declared loaders raise on a renamed column, a dropped column, a value that does not parse, and a date written as 03/04/2024. They read a reordered file correctly by name. They differ on an added column: pandas and DuckDB load the columns they were told about and ignore the new one, while Polars refuses the file. The columns loader accepted the renamed file under the old name, put the ZIP code in customer_id on the reordered file, and guessed the wrong month for the date.

Polars matched the reordered columns by name, although its scan_csv documentation says the schema’s order must match the file’s. Keep the order the same anyway.

Check it worked

Run the tests. They check the column types of both exports in all three loaders, the zeros and NA that must survive, and the shape and date changes that must raise. A round trip writes each frame to CSV and reads it back with the same loader, which must return an equal frame.

PYTHONPATH=vendor python3 test_load.py
test_declared_dtypes_hold_in_every_chunk (__main__.ChunkedReads.test_declared_dtypes_hold_in_every_chunk) ... ok
test_duckdb_df_picks_int64_or_Int64_by_whether_nulls_are_present (__main__.DeclaredTypes.test_duckdb_df_picks_int64_or_Int64_by_whether_nulls_are_present) ... ok
test_duckdb_gives_both_exports_the_same_dtypes (__main__.DeclaredTypes.test_duckdb_gives_both_exports_the_same_dtypes) ... ok
test_leading_zeros_and_namibia_survive_every_loader (__main__.DeclaredTypes.test_leading_zeros_and_namibia_survive_every_loader) ... ok
test_pandas_gives_both_exports_the_same_dtypes (__main__.DeclaredTypes.test_pandas_gives_both_exports_the_same_dtypes) ... ok
test_polars_gives_both_exports_the_same_schema (__main__.DeclaredTypes.test_polars_gives_both_exports_the_same_schema) ... ok
test_each_loader_reads_back_what_it_wrote (__main__.RoundTrip.test_each_loader_reads_back_what_it_wrote) ... ok
test_a_date_in_another_format_or_not_a_date_raises_in_every_loader (__main__.ShapeChanges.test_a_date_in_another_format_or_not_a_date_raises_in_every_loader) ... ok
test_a_renamed_or_dropped_column_or_a_bad_value_raises_in_every_loader (__main__.ShapeChanges.test_a_renamed_or_dropped_column_or_a_bad_value_raises_in_every_loader) ... ok
test_a_reordered_export_loads_by_name (__main__.ShapeChanges.test_a_reordered_export_loads_by_name) ... ok

Ten tests print ok, and the runner ends with OK. The round trip catches what a type check cannot. A ZIP code that passed through an integer on its way out does not come back equal.

When it goes wrong

A chunked read hands you one column with two types. pandas types each chunk on its own, so a ZIP column can be int64 in the early chunks and str in a later one.

PYTHONPATH=vendor python3 chunks.py
pandas read_csv with chunksize=1000 on a 2,500 row export
  chunk 1  zip int64   visits int64
  chunk 2  zip int64   visits int64
  chunk 3  zip str     visits float64
after pd.concat: zip object, holding int and str
  row 1     10001
  row 2002  '02002'
with dtype declared: chunk 1 zip str, chunk 2 zip str, chunk 3 zip str

After pd.concat, row 1 holds the integer 10001 and row 2002 the string '02002' in one object column. Pass the same dtype to the chunked read, and every chunk agrees.

Namibia still disappears after you set na_values. The pandas reference for read_csv says na_values is added to the default list unless keep_default_na is False, and NA is on that list. Set both.

A DuckDB result passes a type check on one file and fails it on the next. .df() picks int64 or Int64 by whether the column holds a NULL, so cast after converting, as with_duckdb does.

When not to do this

Do not declare a schema for a file you open once to look at. Inference is quick, and when you read the frame yourself, a wrong guess shows up in seconds. Declare when an export arrives on a schedule and feeds a report or a model that nobody inspects row by row.

Do not turn ids and ZIP codes back into numbers downstream. A declared string survives this load and loses its zero again in the first astype(int) someone adds for a join.

Do not use DuckDB’s columns option on an export whose producer can rename or reorder columns. It fits a file with no header row, where position is all there is.

Do not pick Polars for its strictness if the upstream team adds columns every month. Each addition stops your load until the schema catches up, which is the price of hearing about every change.

Last verified

Verified 2026-10-21 against Python 3.13.16, pandas 3.0.6, Polars 2.0.0, DuckDB 1.5.6, and NumPy 2.5.4. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci installed the pinned packages into vendor/. The exports are small fixtures written for the page, not a real customer file.

Footnotes

  1. The closest thing CSV has to a specification is RFC 4180, an Informational RFC from October 2005. In its own words, it “does not specify an Internet standard of any kind.” It defines records, fields, quoting, and an optional header line, and it registers the text/csv media type. It says nothing about types. Whether 02134 is a number is a question the format never asks, so every reader on this page answers it on the format’s behalf. ↩︎ Back to text

  2. DuckDB’s default sample of 20,480 rows is stated on its type detection page. DuckDB processes data in vectors, and its internals documentation gives the default vector size as 2,048, so the sample is exactly ten vectors long. Polars settles the same question after 100 rows unless it is told to read more. ↩︎ Back to text

  3. pandas reads nineteen strings as missing by default, listed in the STR_NA_VALUES set in its parser source. Most are what anyone would expect: NA, NULL, None, n/a, and the empty string. Four of them, 1.#IND, -1.#IND, 1.#QNAN, and -1.#QNAN, look like nothing a person would type. The first two are how the Microsoft C runtime printed an indefinite value before Visual Studio 2015, according to Microsoft’s printf documentation. The same page records that formatting infinity to two decimal places printed 1.#J, because the runtime rounded the #INF like any other digits. A comment before the pandas set keeps three spellings of infinity that pandas has since stopped counting as missing. NA is also the code behind Namibia’s top-level domain, .na, which is how a country can drop out of a customer table without an error. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.