Data Science Fundamentals

Importing Data


You have been handed a file called customers.csv and told to find out how many orders came from each region. You do the obvious thing:

Python
import pandas as pddf = pd.read_csv("customers.csv")df.groupby("region")["order_total"].sum()

It runs. You get numbers. You put them in a slide deck. Three days later someone points out that "North" and "north " (with a trailing space) are being counted as different regions, that customer ID 00745 has silently become the integer 745, and that the order_total column is a string because 47 rows contain the text "N/A". Your sums are wrong, and they were wrong from the very first line of code.

Nothing in that snippet was a typo. The bug was that reading a file was treated as a trivial step rather than as the moment where every downstream assumption gets locked in. Importing data is not a formality before the real work. It is the real work, done early, where mistakes are cheap to fix.

This lesson covers where data comes from, how to pull it in from each source, and — most importantly — how to check what you actually got before you build anything on top of it.

Loading is a claim you have to checkName the sourceand its shapeRead withdtypes andparse_dates setCheck row countagainst the sourceAssert theranges youexpectProfilebefore analysingread_csv guesses types silently; stating them turns a wrong guess into an error you can see.
A default read always succeeds — which is exactly why the count of orders per region came out wrong.

The Shape of the Data Landscape

Almost every dataset you will ever load arrives in one of four forms. Knowing which one you are dealing with tells you which failure modes to expect.

SourceTypical formatWhat usually goes wrong
Flat filesCSV, TSV, ExcelEncoding, delimiters, silent type coercion, merged header cells
Semi-structured filesJSON, XML, ParquetNesting, ragged records, missing keys
DatabasesSQLite, PostgreSQL, MySQLHuge result sets, timezone handling, NULL semantics
Web APIsJSON over HTTPPagination, rate limits, authentication, partial failures

Every import is a translation. Something written in one system's rules is being reinterpreted under another's, and translation always loses information unless you say explicitly what to preserve.

Flat Files: CSV, TSV and Excel

A CSV file has no type system. It is text. Every value in it is a string until something decides otherwise, and by default that something is pandas' inference engine, guessing column by column. Guessing is right most of the time, which is exactly what makes it dangerous — you stop checking.

Reading with intent instead of by default

Compare the naive read with a deliberate one:

Python
import pandas as pd# Naive: pandas guesses everythingdf = pd.read_csv("customers.csv")# Deliberate: you declare what you expectdf = pd.read_csv(    "customers.csv",    dtype={"customer_id": "string", "postcode": "string"},  # keep leading zeros    parse_dates=["signup_date"],    na_values=["N/A", "NA", "-", "missing", ""],            # what counts as missing    encoding="utf-8",    thousands=",",                                          # "1,250" -> 1250)

The dtype argument is the single most valuable one. Any identifier — customer ID, postcode, product SKU, phone number — should be read as text. These look numeric but are not: you never add two postcodes together, and the leading zeros carry meaning.

Real-world CSV variations

Files in the wild rarely match the textbook shape. The parameters below handle the usual deviations.

Problem in the fileParameterExample
Semicolons or tabs, not commassepsep=";", sep="\t"
Junk lines before the headerskiprowsskiprows=3
No header row at allheader, namesheader=None, names=["a","b"]
Only some columns neededusecolsusecols=["id","total"]
Non-UTF-8 text (accents turn to mojibake)encodingencoding="latin-1"
Total line at the bottomskipfooterskipfooter=1, engine="python"
European decimals (3,14)decimaldecimal=","

If a file fails to parse and you cannot see why, stop guessing and look at the raw bytes:

Python
with open("messy.csv", "rb") as f:    for _ in range(5):        print(f.readline())

Reading in binary mode ("rb") shows you the literal bytes, including the byte-order mark \xef\xbb\xbf that Excel likes to write at the start of files, and the \r\n line endings that come from Windows. Both cause parse errors that look mysterious in text mode.

Files too large for memory

When a file will not fit in RAM, read it in chunks and reduce each chunk as you go. You never hold the whole thing at once.

Python
totals = []for chunk in pd.read_csv("huge_orders.csv", chunksize=200_000):    chunk = chunk[chunk["status"] == "completed"]    totals.append(chunk.groupby("region")["amount"].sum())result = pd.concat(totals).groupby(level=0).sum()

The pattern is: filter and aggregate inside the loop, combine afterwards. If you append raw chunks to a list and concatenate at the end, you have simply rebuilt the memory problem with extra steps.

Many files, one table

Python
from pathlib import Pathframes = []for path in sorted(Path("data/monthly").glob("*.csv")):    part = pd.read_csv(path)    part["source_file"] = path.name   # keep provenance    frames.append(part)df = pd.concat(frames, ignore_index=True)

Adding the source_file column costs nothing and repays itself the first time one month's numbers look wrong and you need to know which file they came from.

Excel

Excel files carry formatting, formulas and multiple sheets. Read the sheet you want, explicitly:

Python
sheets = pd.read_excel("report.xlsx", sheet_name=None)   # dict of all sheetsprint(list(sheets.keys()))df = pd.read_excel(    "report.xlsx",    sheet_name="Q3 Sales",    skiprows=2,            # branded header block    usecols="B:H",         # spreadsheet column letters)

Watch for merged cells: they produce a value in the first cell and NaN in the rest, which looks like missing data but is really a layout artefact. Forward-filling those columns with df["region"] = df["region"].ffill() usually restores the intended meaning.

JSON and Nested Structures

JSON's problem is the opposite of CSV's. CSV is flat and typeless; JSON has types but arbitrary nesting. A record like this is not a table row:

JSON
{  "order_id": "A-1041",  "customer": {"id": 77, "city": "Leeds"},  "items": [{"sku": "X1", "qty": 2}, {"sku": "Y9", "qty": 1}]}

pd.json_normalize flattens it. The key decision is what one row should represent.

Python
import jsonimport pandas as pdwith open("orders.json") as f:    orders = json.load(f)# One row per order; nested customer fields become customer.id, customer.cityper_order = pd.json_normalize(orders)# One row per item, with order-level fields carried downper_item = pd.json_normalize(    orders,    record_path="items",    meta=["order_id", ["customer", "city"]],)

For newline-delimited JSON — one JSON object per line, common in log exports — use pd.read_json("events.jsonl", lines=True).

Before flattening any nested data, decide in one sentence what a single row means. "One row per order item" is a design decision, and getting it wrong is why counts come out inflated.

Databases

A database gives you something files do not: the ability to make the data source do the filtering. Pulling a whole table into Python and then filtering it is the most common waste of time and memory in data work.

Python
import sqlite3import pandas as pdconn = sqlite3.connect("shop.db")# List what is actually in the databasetables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table'", conn)# Push the work to the database, not to pandasdf = pd.read_sql(    """    SELECT region, DATE(created_at) AS day, SUM(amount) AS revenue    FROM orders    WHERE created_at >= ? AND status = 'completed'    GROUP BY region, day    """,    conn,    params=["2024-01-01"],    parse_dates=["day"],)conn.close()

Note params. Never build SQL by pasting values into an f-string. Apart from the security risk, parameter binding handles quoting and type conversion correctly, which string formatting does not.

For PostgreSQL or MySQL, SQLAlchemy gives a uniform interface:

Python
from sqlalchemy import create_engineengine = create_engine("postgresql+psycopg2://user:password@host:5432/shop")with engine.connect() as conn:    df = pd.read_sql("SELECT * FROM orders LIMIT 1000", conn)

Credentials belong in environment variables, never in the notebook. os.environ["DB_URL"] keeps them out of version control and out of screenshots.

Web APIs

An API call is a file read that can fail halfway through, be rate-limited, or return only part of the answer. Treat each of those as a certainty, not a possibility.

Python
import requestsresp = requests.get(    "https://api.example.com/v1/orders",    params={"since": "2024-01-01", "limit": 100},    headers={"Authorization": f"Bearer {token}"},    timeout=30,)resp.raise_for_status()      # turn a 4xx/5xx into a visible exceptiondata = resp.json()

Two lines there matter more than they look. timeout stops a hung request from freezing your script forever — without it, the default is to wait indefinitely. raise_for_status() converts an HTTP error into a Python exception; without it, a 401 response body ("unauthorised") gets parsed as JSON and flows downstream as though it were data.

Pagination

APIs return results in pages. If you read only the first response, you get the first 100 rows and no warning that 40,000 more exist.

Python
import timedef fetch_all(url, headers, page_size=100, max_pages=500):    rows, page = [], 1    while page <= max_pages:        r = requests.get(url, headers=headers,                         params={"page": page, "per_page": page_size},                         timeout=30)        if r.status_code == 429:            # rate limited            time.sleep(int(r.headers.get("Retry-After", 5)))            continue        r.raise_for_status()        batch = r.json().get("results", [])        if not batch:            break        rows.extend(batch)        page += 1        time.sleep(0.2)                     # be a polite client    return pd.DataFrame(rows)

The max_pages guard exists because a malformed API that always returns the same page will otherwise loop until your disk fills. Bound every loop that talks to a network.

Validating What You Actually Got

Here is the discipline that separates careful work from the story at the top of this lesson. Immediately after loading, before any analysis, run a fixed set of checks. Not to explore the data — to confirm the import did what you expected.

Python
def import_report(df, name="dataset"):    print(f"{name}: {df.shape[0]:,} rows x {df.shape[1]} columns")    print(f"memory: {df.memory_usage(deep=True).sum() / 1e6:.1f} MB")    print(f"duplicate rows: {df.duplicated().sum():,}")    summary = pd.DataFrame({        "dtype": df.dtypes.astype(str),        "missing": df.isna().sum(),        "missing_pct": (df.isna().mean() * 100).round(1),        "unique": df.nunique(),        "sample": [df[c].dropna().iloc[0] if df[c].notna().any() else None                   for c in df.columns],    })    return summaryprint(import_report(df, "customers"))

Read the output against these questions:

CheckWhat a bad answer looks likeWhat it means
Row countExactly 1,000 or 100,000A row limit was silently applied somewhere
Column dtypesA price column is objectSome rows contain text — currency symbols, "N/A"
Unique countsAn ID column has fewer uniques than rowsDuplicates, or a bad join upstream
Missing percentagesA column is 100% missingWrong column selected, or the export dropped it
Categorical values"North" and "north " both presentWhitespace or casing inconsistency
Numeric rangesNegative ages, dates in 1899Sentinel values or spreadsheet date offsets

For categorical columns specifically, print the value counts. It takes seconds and catches the trailing-space class of bug immediately:

Python
for col in df.select_dtypes(include=["object", "string"]).columns:    if df[col].nunique() < 25:        print(f"\n{col}:")        print(df[col].value_counts(dropna=False))

Encoding expectations as assertions

Printing a report only helps if a human reads it. When an import runs on a schedule, encode the checks so failure is loud:

Python
def validate(df):    problems = []    if df["customer_id"].duplicated().any():        problems.append("duplicate customer_id values")    if df["amount"].lt(0).any():        problems.append("negative amounts")    if df["signup_date"].max() > pd.Timestamp.today():        problems.append("signup dates in the future")    expected = {"customer_id", "region", "amount", "signup_date"}    missing = expected - set(df.columns)    if missing:        problems.append(f"missing columns: {sorted(missing)}")    if problems:        raise ValueError("Import validation failed: " + "; ".join(problems))    return df

A pipeline that fails loudly on bad input is far more valuable than one that always produces a number. Silent success on corrupt data is the expensive outcome.

Automated Profiling

For a first look at an unfamiliar dataset, generated profile reports save time. ydata-profiling produces distributions, correlations, missing-value patterns and warnings in one HTML page:

Python
from ydata_profiling import ProfileReportProfileReport(df, title="Customers", minimal=True).to_file("profile.html")

At the time of writing (September 2026) ydata-profiling 4.18 still requires pandas 2 and Python 3.13 or older, so if your project is on pandas 3, install it in a separate environment and profile a saved copy of the data there.

Use minimal=True on anything above roughly 100,000 rows; the full report computes pairwise correlations and interaction plots, and that scales badly. Treat the report as a way to generate questions, not as an analysis. It will tell you that a column is 62% missing; it cannot tell you whether that is because the field was added last March.

What This Means When You Build Something

Write the import as a function, not as loose cells. Give it explicit dtypes, explicit missing-value markers, explicit date parsing, and a validation step that raises on the conditions you know would invalidate the analysis. Keep the raw file untouched on disk and never edit it by hand — every transformation lives in code, so that re-running the script from the original source reproduces exactly what you had.

Python
def load_customers(path):    df = pd.read_csv(        path,        dtype={"customer_id": "string", "postcode": "string"},        parse_dates=["signup_date"],        na_values=["N/A", "NA", "-", ""],    )    df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_")    for col in df.select_dtypes(include=["object", "string"]).columns:        df[col] = df[col].str.strip()    return validate(df)

Those two normalisation lines — stripping and lower-casing column names, stripping whitespace from text values — would have prevented the "North" versus "north " problem entirely. That is the general pattern: most import bugs are not exotic. They are whitespace, types, and assumptions no one wrote down. The fix is to write them down, in code, at the point of entry, where a wrong assumption costs one minute instead of three days.