Introduction to Pandas: Series and DataFrames

pandas series and dataframes are the two structures nearly everything in real-world Python data work builds on top of. This pandas tutorial for beginners covers both, framed clearly in relation to each other — a DataFrame really is just a collection of Series — and ties directly back to the NumPy foundation already built earlier in this series, since pandas doesn't replace NumPy at all; it wraps and extends it.

What Pandas Is and Why It Builds on NumPy

What pandas is

Pandas is an open-source library for data manipulation and analysis, built directly on top of NumPy, that efficiently manages large datasets and offers a rich toolkit for data cleaning, transformation, and analysis.

Two core structures

Pandas revolves around two primary data structures: Series (one-dimensional) and DataFrame (two-dimensional). It also integrates seamlessly with the wider Python data ecosystem — NumPy underneath, Matplotlib for visualization, scikit-learn for machine learning — making it the natural hub connecting most of the tools covered later in this series.

Pandas vs. NumPy: building on, not replacing

Tying this directly back to the NumPy articles earlier in this series: pandas doesn't replace NumPy arrays — it wraps and extends them. Underneath a pandas Series or DataFrame column, the actual data is very often stored as a genuine NumPy array. What pandas adds on top is labels (meaningful row and column names, rather than just numeric position), support for mixed data types within one structure (something a single NumPy array, built around one consistent dtype, doesn't naturally support), and considerably higher-level tools specifically for data analysis — filtering, grouping, merging, handling missing values — that go well beyond what raw NumPy arrays offer directly.

The Series: A Single Labeled Column

What a Series is

A Series is a one-dimensional labeled array, capable of holding data of any type — integers, strings, floating-point numbers, or arbitrary Python objects. It consists of two parts: the actual data values, and an index of labels, one per value.

import pandas as pd

s = pd.Series([10, 20, 30, 40])
print(s)
# 0    10
# 1    20
# 2    30
# 3    40
# dtype: int64
The default index

If nothing else is specified, values are labeled with a default index starting from 0 — visible in the left-hand column of the output above.

Custom index labels
s = pd.Series([85, 90, 78], index=["Alex", "Sam", "Jordan"])
print(s)
# Alex      85
# Sam       90
# Jordan    78
# dtype: int64

print(s["Sam"])   # 90 — looked up by meaningful label, not just position

Passing an explicit index argument lets you look values up by a meaningful name rather than just a numeric position — genuinely useful the moment your data has a natural identifier (a name, a date, a product code) that's more meaningful than "the third item."

Building a Series from a dictionary
scores = {"Alex": 85, "Sam": 90, "Jordan": 78}
s = pd.Series(scores)
print(s)
# Alex      85
# Sam       90
# Jordan    78
# dtype: int64

A dictionary's keys automatically become the index labels, and its values become the Series' data — a natural, intuitive bridge from Python's own built-in dictionary type, covered extensively earlier in this series, into pandas' own data structures.

The DataFrame: A Table Made of Series

The framing that ties everything together

This is the single most useful mental model for understanding pandas: a Series is essentially a column, and a DataFrame is a table made up of a collection of Series — one Series per column, all sharing the same row index.

A formal definition

A DataFrame is a two-dimensional, size-mutable, potentially heterogeneous tabular data structure with labeled axes for both rows and columns — comparable to a spreadsheet, or a single table in a relational database.

Creating a DataFrame from a dictionary
data = {
    "name": ["Alex", "Sam", "Jordan"],
    "age": [30, 25, 35],
    "city": ["Nagpur", "Austin", "London"],
}

df = pd.DataFrame(data)
print(df)
#      name  age    city
# 0    Alex   30  Nagpur
# 1     Sam   25  Austin
# 2  Jordan   35  London

This is the simplest, most common way to build a DataFrame from scratch, especially for quick testing or small examples: each key becomes a column name, and its corresponding list of values becomes that column's data — with pandas automatically assigning a default 0, 1, 2, ... row index, exactly the same default behavior covered for a plain Series above.

Confirming the "DataFrame as a collection of Series" framing
print(df["age"])
# 0    30
# 1    25
# 2    35
# Name: age, dtype: int64

print(type(df["age"]))   # <class 'pandas.core.series.Series'>

Pulling out a single column from df returns a genuine Series object — direct, concrete confirmation of the framing from earlier in this section: a DataFrame really is structured as a collection of Series, one per column, all sharing the DataFrame's row index.

Accessing and Inspecting DataFrame Data

The subtle but important return-type distinction

This genuinely trips up a lot of beginners, so it's worth demonstrating explicitly:

# A single column name — returns a Series
single_column = df["age"]
print(type(single_column))   # <class 'pandas.core.series.Series'>

# Double brackets, or a list of column names — returns a DataFrame
subset = df[["age"]]
print(type(subset))   # <class 'pandas.core.frame.DataFrame'>

multi_column = df[["name", "age"]]
print(type(multi_column))   # <class 'pandas.core.frame.DataFrame'>

df["age"] (single brackets, single column name) returns a Series. df[["age"]] (double brackets, even for just one column) returns a DataFrame — a table with just that one column, rather than the flattened Series version. This distinction matters practically: some operations behave differently on a Series versus a single-column DataFrame, and this is precisely the kind of subtle mismatch that produces a confusing error message the first time you accidentally get one when you expected the other.

.loc[] for label-based access
print(df.loc[0])
# name      Alex
# age         30
# city    Nagpur
# Name: 0, dtype: object

print(df.loc[0, "age"])   # 30 — row 0, specifically the 'age' column

.loc[] accesses data by label — row and (optionally) column labels — rather than purely by position, giving you precise, explicit control over exactly which row and column you're referring to.

Quick inspection habits worth building immediately
print(df.head())   # the first 5 rows by default (or fewer, if the DataFrame is smaller)
print(df.info())
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 3 entries, 0 to 2
# Data columns (total 3 columns):
#  #   Column  Non-Null Count  Dtype
# ---  ------  --------------  -----
#  0   name    3 non-null      object
#  1   age     3 non-null      int64
#  2   city    3 non-null      object
print(df.shape)   # (3, 3) — 3 rows, 3 columns

.head() shows the first several rows — a fast first look at any new dataset. .info() shows column names, data types, and non-null counts per column — genuinely valuable for immediately spotting missing data (a column with fewer non-null entries than the total row count) without needing a separate, explicit check. .shape gives you row and column counts directly — the pandas equivalent of checking a NumPy array's .shape attribute, covered in the earlier NumPy articles in this series.

Loading real data with read_csv()
df = pd.read_csv("employees.csv")
print(df.head())

Rather than typing data by hand (as every example so far in this article has done), pd.read_csv() reads an external CSV file directly into a DataFrame in a single line — tying directly back to the earlier CSV article in this series, and setting up the natural next step: working with genuinely real datasets, rather than small, hand-typed examples.

Why Series and DataFrames Matter for Real Analysis

The practical payoff

This is what all of the above actually enables in practice: exploring a dataset by calculating statistics (mean, median, max, min — directly building on the NumPy statistical functions covered earlier in this series, since pandas' own equivalents work almost identically), cleaning data by handling missing values or filtering out unwanted rows, visualizing it with Matplotlib, and writing cleaned, processed results back out to a file or database when you're done.

print(df["age"].mean())     # average age across the DataFrame
print(df["age"].max())       # oldest age in the dataset
Handling missing data as a first-class concern
import numpy as np

data_with_gaps = pd.DataFrame({
    "name": ["Alex", "Sam", "Jordan"],
    "age": [30, np.nan, 35],
})

print(data_with_gaps.dropna())   # removes any row containing a missing value
print(data_with_gaps.fillna(0))   # replaces missing values with a specified fallback, here 0

.dropna() and .fillna() handle missing values directly and conveniently — a capability plain NumPy arrays don't offer nearly as smoothly, even with the NaN-aware functions (np.nanmean(), and similar) covered in the earlier NumPy statistical functions article. Pandas treats missing data as a genuinely first-class, expected part of working with real-world datasets, rather than something you need to work around manually every time.

What comes next

This article covered the two foundational structures — Series and DataFrame — and how they relate to each other. Natural follow-ups in a pandas-focused track include filtering and selecting data based on conditions, grouping and aggregating data (computing statistics per category, mirroring the axis-based aggregation covered in the earlier NumPy statistical functions article, but organized around meaningful group labels instead of raw array dimensions), merging multiple DataFrames together, and reading and writing across the many file formats pandas supports beyond just CSV.