Pandas · lesson 2 of 13
Introduction to Pandas
Build your first Pandas DataFrame and Series, load a CSV with real options, and learn why info() prints None and how index alignment creates NaN.
By SkillByExample · Updated
What Is Pandas?
Pandas is the standard Python library for working with tabular data. It gives you two core
structures — Series (one labeled column) and DataFrame (a table of them) — plus the tools to
load, clean, reshape, group, and export that data.
pip install pandas
Every example below was run with Pandas 3.0.5 on Python 3.12.
Your First DataFrame
A DataFrame is usually built from a dict: each key becomes a column name.
import pandas as pd
sales = pd.DataFrame({
"product": ["Widget A", "Widget B", "Widget C", "Widget A", "Widget B"],
"region": ["North", "North", "South", "South", "North"],
"quantity": [120, 85, 200, 150, 95],
"revenue": [1200.0, 1020.0, 2400.0, 1800.0, 1140.0],
})
print(sales)
print(sales.shape)
print(sales.dtypes)
$ python first_df.py
product region quantity revenue
0 Widget A North 120 1200.0
1 Widget B North 85 1020.0
2 Widget C South 200 2400.0
3 Widget A South 150 1800.0
4 Widget B North 95 1140.0
(5, 4)
product str
region str
quantity int64
revenue float64
dtype: object
Pandas added the 0-4 row index on its own, and shape reports (rows, columns). The text
columns have dtype str — Pandas 3’s dedicated string type — while the numbers were inferred as
int64 and float64.
Inspecting a DataFrame
info() and describe() are the first two calls most people make on new data.
import pandas as pd
sales = pd.DataFrame({
"product": ["Widget A", "Widget B", "Widget C", "Widget A", "Widget B"],
"quantity": [120, 85, 200, 150, 95],
"revenue": [1200.0, 1020.0, 2400.0, 1800.0, 1140.0],
})
print(sales.info())
print(sales.describe())
$ python inspect_df.py
<class 'pandas.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 product 5 non-null str
1 quantity 5 non-null int64
2 revenue 5 non-null float64
dtypes: float64(1), int64(1), str(1)
memory usage: 252.0 bytes
None
quantity revenue
count 5.000000 5.000000
mean 130.000000 1512.000000
std 46.502688 580.792562
min 85.000000 1020.000000
25% 95.000000 1140.000000
50% 120.000000 1200.000000
75% 150.000000 1800.000000
max 200.000000 2400.000000
Notice the stray None after the info() summary. info() prints its report itself and
returns None, so wrapping it in print() prints that return value too — call it on its own.
describe() returns a DataFrame, and it summarizes only the numeric columns by default. (Don’t
name this file inspect.py: it would shadow Python’s own inspect module, and import pandas
fails with a circular-import error.)
Series
A Series is one labeled column. You can select from it by label, by position, or with a boolean mask.
import pandas as pd
monthly = pd.Series(
[42000, 38000, 51000, 47000, 55000],
index=["Jan", "Feb", "Mar", "Apr", "May"],
name="revenue",
)
print(monthly["Mar"]) # by label
print(monthly.iloc[0]) # by position
print(monthly[monthly > 45000]) # by boolean mask
$ python series.py
51000
42000
Mar 51000
Apr 47000
May 55000
Name: revenue, dtype: int64
monthly["Mar"] looks the value up by its index label, while .iloc[0] ignores labels and
takes the first position. The mask monthly > 45000 is itself a Series of True/False, and
indexing with it keeps only the True rows — labels included.
Arithmetic Aligns on Labels
Adding two Series pairs values by index label, not by position.
import pandas as pd
monthly = pd.Series([42000, 38000, 51000], index=["Mar", "Apr", "May"])
bonus = pd.Series([1000, 1000, 1000], index=["Apr", "May", "Jun"])
print(monthly + bonus)
print(monthly.add(bonus, fill_value=0))
$ python align.py
Apr 39000.0
Jun NaN
Mar NaN
May 52000.0
dtype: float64
Apr 39000.0
Jun 1000.0
Mar 42000.0
May 52000.0
dtype: float64
Only Apr and May exist in both Series, so only they get a real sum; Mar and Jun have no
partner and become NaN. Because NaN is a float, the whole result turns into float64, and
the combined labels come back sorted (Apr, Jun, Mar, May), not in either input’s order.
.add(..., fill_value=0) treats a missing label as zero instead.
Loading a CSV
read_csv accepts a path or any file-like object; here the CSV lives in a string so the
example runs anywhere.
import io
import pandas as pd
raw = """id,name,revenue,created_at,notes
1,Alice,1200.50,2026-01-05,ok
2,Bob,N/A,2026-01-06,late
3,Carol,-,2026-01-09,ok
"""
df = pd.read_csv(
io.StringIO(raw),
usecols=["id", "name", "revenue", "created_at"],
na_values=["N/A", "-"],
parse_dates=["created_at"],
)
print(df)
print(df.dtypes)
print(df.isna().sum())
$ python load_csv.py
id name revenue created_at
0 1 Alice 1200.5 2026-01-05
1 2 Bob NaN 2026-01-06
2 3 Carol NaN 2026-01-09
id int64
name str
revenue float64
created_at datetime64[us]
dtype: object
id 0
name 0
revenue 2
created_at 0
dtype: int64
usecols dropped notes before it was ever loaded. na_values turned both N/A and - into
real missing values, which is why revenue could be parsed as float64 at all — left alone,
those strings would have forced the column to text. parse_dates gave created_at a real datetime
dtype — datetime64[us], since Pandas 3 defaults to microsecond resolution.
Everyday Operations
Selecting, filtering, sorting, and grouping cover most day-to-day Pandas work.
import pandas as pd
df = pd.DataFrame({
"name": ["Alice", "Bob", "Carol", "Dave", "Eve"],
"dept": ["Eng", "Eng", "Sales", "Sales", "Eng"],
"salary": [95000, 88000, 72000, 76000, 105000],
"years": [5, 3, 7, 4, 8],
})
print(df[df["years"] >= 5][["name", "years"]])
print(df.sort_values("salary", ascending=False).head(2))
print(df.groupby("dept")["salary"].agg(["mean", "max", "count"]))
$ python everyday.py
name years
0 Alice 5
2 Carol 7
4 Eve 8
name dept salary years
4 Eve Eng 105000 8
0 Alice Eng 95000 5
mean max count
dept
Eng 96000.0 105000 3
Sales 74000.0 76000 2
The filtered result keeps the original row labels (0, 2, 4) rather than renumbering, and
sort_values carries labels along too — the index identifies rows, it isn’t a row counter.
groupby turns each distinct dept into one row of the summary, with dept as the new index.
Practice
- In
first_df.py, add apricecolumn computed asrevenue / quantityand print the DataFrame. What dtype does the new column get?
Solution
import pandas as pd
sales = pd.DataFrame({
"product": ["Widget A", "Widget B", "Widget C"],
"quantity": [120, 85, 200],
"revenue": [1200.0, 1020.0, 2400.0],
})
sales["price"] = sales["revenue"] / sales["quantity"]
print(sales)
print(sales["price"].dtype)
$ python practice_price.py
product quantity revenue price
0 Widget A 120 1200.0 10.0
1 Widget B 85 1020.0 12.0
2 Widget C 200 2400.0 12.0
float64
Division always produces floats, so price is float64 even though quantity is int64.
- Fix
inspect_df.pyso the summary is printed without the trailingNone.
Solution
import pandas as pd
sales = pd.DataFrame({"quantity": [120, 85], "revenue": [1200.0, 1020.0]})
sales.info()
$ python practice_info.py
<class 'pandas.DataFrame'>
RangeIndex: 2 entries, 0 to 1
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 quantity 2 non-null int64
1 revenue 2 non-null float64
dtypes: float64(1), int64(1)
memory usage: 164.0 bytes
Calling info() directly lets it print its report; there is no return value left to print.
- In
align.py, the monthsMarandJunproducedNaN. Produce a result that has only the months present in both Series, with noNaNat all.
Solution
import pandas as pd
monthly = pd.Series([42000, 38000, 51000], index=["Mar", "Apr", "May"])
bonus = pd.Series([1000, 1000, 1000], index=["Apr", "May", "Jun"])
both = (monthly + bonus).dropna()
print(both)
$ python practice_align.py
Apr 39000.0
May 52000.0
dtype: float64
dropna() removes the unmatched labels. The values stay float64, since the NaNs existed
before they were dropped.
- Using the DataFrame from
everyday.py, print the name of the highest-paid person in each department.
Solution
import pandas as pd
df = pd.DataFrame({
"name": ["Alice", "Bob", "Carol", "Dave", "Eve"],
"dept": ["Eng", "Eng", "Sales", "Sales", "Eng"],
"salary": [95000, 88000, 72000, 76000, 105000],
})
top = df.loc[df.groupby("dept")["salary"].idxmax(), ["dept", "name", "salary"]]
print(top)
$ python practice_top.py
dept name salary
4 Eng Eve 105000
3 Sales Dave 76000
idxmax() returns the row label of each group’s maximum, and .loc fetches exactly those rows.