Skip to main content
Pandas · lesson 2 of 13
Pandas beginner 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

  1. In first_df.py, add a price column computed as revenue / quantity and 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.

  1. Fix inspect_df.py so the summary is printed without the trailing None.
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.

  1. In align.py, the months Mar and Jun produced NaN. Produce a result that has only the months present in both Series, with no NaN at 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.

  1. 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.

Frequently Asked Questions

What is the difference between a Pandas Series and a DataFrame?
A Series is a 1-D labeled array — a single column of values with an index. A DataFrame is a 2-D table made of Series that share one index, like a spreadsheet or SQL table. Selecting one column of a DataFrame with df['col'] gives you back a Series.
Why does print(df.info()) print None at the end?
DataFrame.info() writes its summary straight to standard output and returns None. Wrapping it in print() therefore prints the summary and then prints the None return value. Call df.info() on its own line instead.
Why did adding two Pandas Series give me NaN values?
Arithmetic between Series aligns on index labels, not on position. A label that exists in only one of the two Series has nothing to pair with, so the result for that label is NaN, and the column becomes float64 to hold it. Use s1.add(s2, fill_value=0) to treat missing labels as zero.
What dtype does Pandas 3 use for text columns?
Pandas 3.0 stores text in a dedicated string dtype that prints as str, instead of the generic object dtype older versions used. It holds only strings or missing values, and missing text shows as NaN. Code that checks dtype == object for text columns needs updating.
Is Pandas good for large datasets?
Pandas works well for data that fits comfortably in memory, often up to tens of millions of rows on a laptop. Beyond that, tools such as Polars, DuckDB, Dask, or PySpark process data lazily, in parallel, or out of core.