This pandas introduction gets you started fast: create Series and DataFrames, read CSV, Excel, and JSON files, inspect data with head, info, and describe, and run your first data analysis in minutes.
“If you torture the data long enough, it will confess to anything.”
Ronald Coase
Last Updated: July 2026 | Tested on: Python 3.14.6, Pandas 3.0.3 | Difficulty: Intermediate | Reading Time: 16 minutes
You have a spreadsheet of sales numbers. You want the total per city, the rows where revenue beat a target, and a quick average, and you want it in seconds, not after twenty minutes of dragging formulas around. NumPy gives you fast arrays, but real data has column names, mixed types, missing values, and dates. You need something that treats data like a spreadsheet but lets you drive it with code. That is what this pandas introduction is about. Pandas is the single most important library in the Python data world: if you touch data in Python, you touch Pandas.
Think of Pandas as a spreadsheet you can talk to in code. A spreadsheet has named columns and numbered rows, and you scroll, sort, and filter by clicking. Pandas gives you the same grid, but you ask for what you want in one line and it answers instantly, even when the grid has a few million rows. Under the hood Pandas sits on top of NumPy, so every column is really a fast NumPy array. On top of that it adds labeled rows and columns, automatic alignment when you combine data, and hundreds of ready-made methods for cleaning and analysis. You stop tracking “which column was that again” by hand.
Here is the everyday version. Rahul, a 27-year-old analyst, once loaded a two-million-row sales file, cleaned it, grouped it by region, and printed a summary report in about a dozen lines. Opening the same file in Excel froze his laptop. That moment, when the spreadsheet gives up and the code keeps going, is exactly when Pandas earns its place in your toolkit.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram breaks a Pandas DataFrame into its three parts: the index (row labels), the columns (each one a named Series), and the values (the NumPy array underneath). The inspection methods .head(), .info(), .describe(), and .shape each point to the part they reveal. Keep this pandas DataFrame anatomy picture handy. The first thing you do with any new dataset is call these methods to learn its shape, its column types, and its summary stats, well before you write a single line of analysis.
Table of Contents
Prerequisites
Complete NumPy introduction. Familiarity with dictionaries tutorial and CSV and JSON tutorial is helpful.
Install and Verify
One command sets up everything this pandas introduction needs. Run the pip line below, then run the small verify script. If you see a version number and a tiny table print out, you are ready. If you see ModuleNotFoundError: No module named 'pandas' instead, the install did not land in the Python you are running, so check that pip and your interpreter point to the same place.
📄 Terminal: install Pandas
pip install pandas
📄 verify_pandas.py: check your installation
import pandas as pd
print(f"Pandas version: {pd.__version__}")
df = pd.DataFrame({"name": ["Rahul", "Viraj"], "age": [28, 25]})
print(df)
▶ Output
Pandas version: 3.0.3
name age
0 Rahul 28
1 Viraj 25
Series: Where Every Pandas Introduction Starts
A Series is a one-dimensional labeled array. Picture a single column lifted out of a spreadsheet: it has values, and it has an index (the row labels) sitting next to them. Every column inside a DataFrame is really a Series, so once you get Series, DataFrames stop feeling scary. A Series is like one column of names in your phone contacts: each name (the value) sits on its own row, and the row position or label tells you exactly where to find it.
📄 series_basics.py: creating and using a Series
import pandas as pd
# From a list
scores = pd.Series([85, 92, 78, 95, 88], name="exam_score")
print(f"Series:\n{scores}\n")
# With custom index
scores_named = pd.Series(
[85, 92, 78, 95, 88],
index=["Rahul", "Niranjan", "Pravin", "Viraj", "Sardar"],
name="exam_score"
)
print(f"Named index:\n{scores_named}\n")
# Access by label and position
print(f"Viraj's score: {scores_named['Viraj']}")
print(f"Position 0: {scores_named.iloc[0]}")
print(f"Above 85:\n{scores_named[scores_named > 85]}\n")
# From a dictionary
populations = pd.Series({
"Mumbai": 20_411_000,
"Delhi": 16_787_000,
"Bangalore": 8_443_000
})
print(f"Populations:\n{populations}")
print(f"\nMean: {populations.mean():,.0f}")
print(f"dtype: {populations.dtype}")
▶ Output
Series: 0 85 1 92 2 78 3 95 4 88 Name: exam_score, dtype: int64 Named index: Rahul 85 Niranjan 92 Pravin 78 Viraj 95 Sardar 88 Name: exam_score, dtype: int64 Viraj's score: 95 Position 0: 85 Above 85: Niranjan 92 Viraj 95 Sardar 88 Name: exam_score, dtype: int64 Populations: Mumbai 20411000 Delhi 16787000 Bangalore 8443000 dtype: int64 Mean: 15,213,667 dtype: int64
What happened here: The first Series got the default index 0, 1, 2, and so on, just like row numbers in a spreadsheet. The second one used names as the index, so you can pull a value by label with scores_named['Viraj'] or by position with .iloc[0]. The boolean filter scores_named[scores_named > 85] keeps only the rows that pass the test, which is the same idea as filtering a spreadsheet, just shorter. Building a Series from a dictionary turns the keys into the index automatically. The dtype: int64 line is Pandas telling you it stored these as 64-bit integers, the fast NumPy type underneath.
DataFrame: The Star of the Show
A DataFrame is the whole spreadsheet: many columns, each one a Series, lined up and sharing one index. This is the object you will spend most of your data career with, which is why any honest pandas introduction spends most of its time here. The most common way to build one is from a dictionary, where each key becomes a column name and each list becomes that column’s values. Once you have a DataFrame, the very first thing to do is look at it. The five methods below, .shape, .dtypes, .head(), .info(), and .describe(), are your “get to know the data” routine.
📄 dataframe_basics.py: creating and inspecting a DataFrame
import pandas as pd
# From a dictionary of lists (most common)
df = pd.DataFrame({
"name": ["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"],
"age": [28, 31, 25, 29, 27],
"department": ["Engineering", "Data Science", "Engineering", "Marketing", "Data Science"],
"salary": [75000, 82000, 71000, 65000, 78000]
})
print(f"DataFrame:\n{df}\n")
# Inspection methods
print(f"Shape: {df.shape}") # (rows, columns)
print(f"Columns: {list(df.columns)}")
print(f"Dtypes:\n{df.dtypes}\n")
print(f"Head (first 3):\n{df.head(3)}\n")
print(f"Info:")
df.info()
print(f"\nDescribe (stats):\n{df.describe()}")
▶ Output
DataFrame:
name age department salary
0 Rahul 28 Engineering 75000
1 Niranjan 31 Data Science 82000
2 Viraj 25 Engineering 71000
3 Pravin 29 Marketing 65000
4 Sardar 27 Data Science 78000
Shape: (5, 4)
Columns: ['name', 'age', 'department', 'salary']
Dtypes:
name str
age int64
department str
salary int64
dtype: object
Head (first 3):
name age department salary
0 Rahul 28 Engineering 75000
1 Niranjan 31 Data Science 82000
2 Viraj 25 Engineering 71000
Info:
<class 'pandas.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 name 5 non-null str
1 age 5 non-null int64
2 department 5 non-null str
3 salary 5 non-null int64
dtypes: int64(2), str(2)
memory usage: 292.0 bytes
Describe (stats):
age salary
count 5.000000 5.000000
mean 28.000000 74200.000000
std 2.236068 6534.523701
min 25.000000 65000.000000
25% 27.000000 71000.000000
50% 28.000000 75000.000000
75% 29.000000 78000.000000
max 31.000000 82000.000000
What happened here: One small dictionary became a full table. .shape told you it has 5 rows and 4 columns. .dtypes shows the text columns as str and the number columns as int64. If you learned Pandas a couple of years ago, you may remember text columns showing up as object instead. That changed: in Pandas 3.0.3 a plain text column gets a real str dtype by default, which is clearer and lighter on memory.
You will only see object now when a column genuinely mixes types. .head(3) peeks at the first three rows, .info() gives a per-column summary with null counts and memory use (the exact byte count depends on your setup, it reads a bit higher if you have pyarrow installed, so do not worry if yours differs), and .describe() runs count, mean, std, min, max, and the quartiles on the numeric columns in one shot. That five-method routine is how you size up any new dataset in under a minute.
Reading Data: CSV, Excel, JSON
Real data lives in files, not in dictionaries you type by hand, so a pandas introduction has to cover the file readers early. Think of these readers as a universal translator at an airport: hand over a file in almost any common format and you get back the same clean table you already know how to work with. Pandas reads the common formats with one line each: read_csv, read_excel, and read_json. The example below builds a CSV (comma-separated values) file in memory so you can run it as-is with no file on disk, but the commented lines show exactly how you would load a real file or even a URL.
📄 reading_data.py: load data from files
import pandas as pd
# CSV, the most common format
# df = pd.read_csv("sales.csv")
# df = pd.read_csv("sales.csv", encoding="utf-8", parse_dates=["date"])
# Excel
# df = pd.read_excel("report.xlsx", sheet_name="Q1")
# JSON
# df = pd.read_json("api_response.json")
# From a URL (works with any public CSV)
# df = pd.read_csv("https://example.com/data.csv")
# Creating sample data for demonstration
import io
csv_data = """name,age,city,salary
Rahul,28,Mumbai,75000
Niranjan,31,Pune,82000
Viraj,25,Mumbai,71000
Pravin,29,Delhi,65000
Sardar,27,Pune,78000
Prathamesh,33,Mumbai,91000"""
df = pd.read_csv(io.StringIO(csv_data))
print(f"Loaded CSV ({df.shape[0]} rows, {df.shape[1]} columns):\n{df}\n")
# Column access
print(f"Names: {list(df['name'])}")
print(f"Mean salary: {df['salary'].mean():,.0f}")
print(f"Cities: {df['city'].unique().tolist()}")
print(f"City counts:\n{df['city'].value_counts()}")
▶ Output
Loaded CSV (6 rows, 4 columns):
name age city salary
0 Rahul 28 Mumbai 75000
1 Niranjan 31 Pune 82000
2 Viraj 25 Mumbai 71000
3 Pravin 29 Delhi 65000
4 Sardar 27 Pune 78000
5 Prathamesh 33 Mumbai 91000
Names: ['Rahul', 'Niranjan', 'Viraj', 'Pravin', 'Sardar', 'Prathamesh']
Mean salary: 77,000
Cities: ['Mumbai', 'Pune', 'Delhi']
City counts:
city
Mumbai 3
Pune 2
Delhi 1
Name: count, dtype: int64
What happened here: read_csv turned raw comma-separated text into a clean table, no manual splitting or parsing. After that, every column is a Series you can poke at: df['salary'].mean() averages a column, .unique() lists the distinct values (here turned into a plain list with .tolist()), and .value_counts() counts how often each value shows up, already sorted from most to least. In a real project you would swap the in-memory text for a file path or a URL on the commented lines and nothing else would change.
Basic Operations and Column Math
This is where Pandas really starts to pay off. You do math on whole columns at once, no loops. It is like typing a formula once at the top of a spreadsheet column and watching it fill every row instantly, except here you never touch the mouse. Writing df["salary"] * df["bonus_pct"] / 100 runs the calculation across every row in one go, which is both shorter to write and far faster than looping. Filtering works the same way: you write a condition once and Pandas keeps the rows that match. This is the everyday rhythm of data work, add a column, filter, sort, repeat.
📄 operations.py: adding columns, filtering, sorting
import pandas as pd
df = pd.DataFrame({
"name": ["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"],
"salary": [75000, 82000, 71000, 65000, 78000],
"bonus_pct": [10, 15, 8, 12, 11]
})
# Add computed column
df["bonus"] = df["salary"] * df["bonus_pct"] / 100
df["total"] = df["salary"] + df["bonus"]
print(f"With computed columns:\n{df}\n")
# Filtering
high_earners = df[df["total"] > 80000]
print(f"Total > 80K:\n{high_earners}\n")
# Sorting
sorted_df = df.sort_values("total", ascending=False)
print(f"Sorted by total (descending):\n{sorted_df}\n")
# Multiple conditions
result = df[(df["salary"] >= 70000) & (df["bonus_pct"] >= 10)]
print(f"Salary >= 70K AND bonus >= 10%:\n{result}")
▶ Output
With computed columns:
name salary bonus_pct bonus total
0 Rahul 75000 10 7500.0 82500.0
1 Niranjan 82000 15 12300.0 94300.0
2 Viraj 71000 8 5680.0 76680.0
3 Pravin 65000 12 7800.0 72800.0
4 Sardar 78000 11 8580.0 86580.0
Total > 80K:
name salary bonus_pct bonus total
0 Rahul 75000 10 7500.0 82500.0
1 Niranjan 82000 15 12300.0 94300.0
4 Sardar 78000 11 8580.0 86580.0
Sorted by total (descending):
name salary bonus_pct bonus total
1 Niranjan 82000 15 12300.0 94300.0
4 Sardar 78000 11 8580.0 86580.0
0 Rahul 75000 10 7500.0 82500.0
2 Viraj 71000 8 5680.0 76680.0
3 Pravin 65000 12 7800.0 72800.0
Salary >= 70K AND bonus >= 10%:
name salary bonus_pct bonus total
0 Rahul 75000 10 7500.0 82500.0
1 Niranjan 82000 15 12300.0 94300.0
4 Sardar 78000 11 8580.0 86580.0
What happened here: Two new columns appeared without a single loop. df["bonus"] = df["salary"] * df["bonus_pct"] / 100 computed the bonus for all five people at once, and total added it on top. The filter df[df["total"] > 80000] kept only the high earners, sort_values reordered the rows, and the last filter combined two conditions with &. One thing to watch: wrap each condition in parentheses, because & binds tighter than the comparison operators. Forget the parentheses and Pandas throws an error.
Ecosystem: Polars as a Modern Alternative
Pandas is the established standard with the biggest ecosystem around it. But for very large datasets (millions of rows), Polars is a Rust-powered alternative that is much faster and lighter on memory. Polars 1.41.2 is mature enough for production, and you will compare the two side by side in the Pandas vs Polars comparison. For now, learn Pandas first. It is the one that scikit-learn, Matplotlib, Seaborn, and almost every data science tool expects you to hand them.
Common Mistakes
📄 Mistake 1: looping with iterrows() when a column operation exists
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
# SLOW: row-by-row loop, painful on large data
for idx, row in df.iterrows():
df.at[idx, "c"] = row["a"] + row["b"]
# FAST: one column operation, runs on every row at once
df["c"] = df["a"] + df["b"]
Why it matters: iterrows() walks one row at a time in slow Python. The column version pushes the work down into fast C and NumPy code, so on a million rows it can be dozens of times quicker. If you find yourself writing a for loop over a DataFrame, stop and ask whether a column operation does the same job.
📄 Mistake 2: chained indexing that silently does nothing
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
# WRONG: chained indexing. Under Copy-on-Write this updates a
# throwaway copy, raises ChainedAssignmentError, and leaves df unchanged.
# df[df["a"] > 1]["b"] = 99
# RIGHT: one .loc call, selects rows and column together, then assigns
df.loc[df["a"] > 1, "b"] = 99
Why it matters: Pandas 3.0.3 turns on Copy-on-Write by default. With it, df[df["a"] > 1]["b"] = 99 writes to a temporary copy, never the real df, so Pandas raises a ChainedAssignmentError and your data stays exactly as it was. The fix is the same one experienced users have always reached for: do the selection and the assignment in a single .loc call. Think of .loc[rows, column] as giving Pandas one clear instruction instead of two half-instructions it has to guess about.
Practice Exercises
- Exercise 1: Create a DataFrame from a dict, display first 5 rows.
- Exercise 2: Read CSV, calculate summary statistics for numeric columns.
- Exercise 3: Load, clean, and export a summary report.
Conclusion
This pandas introduction gave you the two objects the entire Pandas world is built on: the Series, one labeled column, and the DataFrame, the whole labeled table made of many Series sharing an index. You can install and verify Pandas, build a DataFrame from a dictionary, read real data from CSV, Excel, and JSON, size up a fresh dataset with .head(), .info(), and .describe(), and do column math, filtering, and sorting without writing a single loop. That is the core rhythm of everyday data work, and you will repeat it on almost every dataset you ever touch.
Next, you go deeper into pulling out exactly the rows and columns you want with Pandas: Data Selection with loc, iloc, Boolean Indexing. For the full path from first steps to job-ready, keep going through the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is the difference between Pandas and NumPy?
NumPy gives you fast numerical arrays with one data type per array. Pandas gives you labeled DataFrames with a different type allowed per column (strings, numbers, dates). Pandas is built on top of NumPy, so this pandas introduction assumes a little NumPy first. Use NumPy for pure math, and Pandas for tabular data with named columns.
How large a dataset can Pandas handle?
Pandas works well for data that fits in memory, often up to a few GB. For bigger data, reach for Polars (faster, lower memory), Dask (distributed Pandas), or load in chunks with read_csv(chunksize=N). The Pandas vs Polars comparison walks through these options.
Which Pandas version should I learn in 2026?
Learn Pandas 3.x. Pandas 3.0.3 makes Copy-on-Write the default, which removes a whole class of surprise mutations, and it gives text columns a real str dtype instead of the old object dtype. The core API (Application Programming Interface) you learn here is the same one used across the modern data stack, so you are learning current patterns, not legacy ones.
Why do text columns show str instead of object in Pandas 3.0.3?
In Pandas 3.0.3 a plain text column gets a dedicated str dtype by default, which is clearer to read and uses less memory than the old object dtype. You will still see object when a single column genuinely mixes types, for example strings and numbers together. If you need the older behavior you can still opt into it, but str is the sensible default now.
Why use import pandas as pd?
For the same reason as import numpy as np: it is a universal convention. Every tutorial, doc page, and team uses pd. It saves typing and makes your code instantly readable to other data people.
Interview Questions on Pandas
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: What is the difference between a Series and a DataFrame?
A Series is a single one-dimensional labeled column: values plus an index sitting next to them. A DataFrame is a two-dimensional table made of many Series that share one index, so each column can hold a different dtype (text, numbers, dates). Every column you pull out of a DataFrame is itself a Series, which is why learning Series first makes DataFrames click.
Q: You read a sales CSV and the salary column comes in as text instead of int64, so .mean() fails. What do you check first?
Inspect the raw values for stray non-numeric characters like currency symbols, commas used as thousands separators, or empty strings, since a single bad cell forces the whole column to text. Convert it with pd.to_numeric(df["salary"], errors="coerce"), which turns junk into NaN, or clean the strings first. You can also pass thousands="," or a converter to read_csv so the column parses correctly on load.
Q: A colleague’s script loops over a million-row DataFrame with iterrows() and takes minutes. How do you speed it up?
Replace the row loop with a vectorized column operation, for example df["c"] = df["a"] + df["b"], which pushes the work down into fast C and NumPy code and can be dozens of times quicker. If the logic is too complex for plain arithmetic, try .apply() or a boolean mask before reaching for a Python loop. Treat iterrows() as a last resort, not a default.
Q: Why does df[df["a"] > 1]["b"] = 99 fail to update your DataFrame in Pandas 3.0.3?
That is chained indexing: the first bracket returns a temporary copy, and the assignment writes to that throwaway object instead of the original df. Under Copy-on-Write, which is the default in Pandas 3.0.3, Pandas raises a ChainedAssignmentError to warn you rather than silently doing nothing. Do the selection and assignment in one step with df.loc[df["a"] > 1, "b"] = 99.
Q: What does df.describe() return, and which columns does it cover by default?
It returns count, mean, std, min, the 25/50/75% quartiles, and max for each numeric column. Text columns are skipped by default, though df.describe(include="all") adds count, unique, top, and freq for them. It is the fastest way to sanity-check ranges and spot outliers on a brand new dataset.
Q: How do you filter a DataFrame on more than one condition at once?
Combine boolean masks with & (and) and | (or), wrapping each condition in parentheses, for example df[(df["salary"] >= 70000) & (df["bonus_pct"] >= 10)]. The parentheses matter because & binds tighter than the comparison operators, so leaving them out raises an error. Use the bitwise operators & and |, not the Python keywords and and or, which do not work element by element.
Reference: the complete, always-current details live in pandas official documentation.
Related Posts
Previous: NumPy Linear Algebra: dot, matmul, eigenvalues
Next: Pandas: Data Selection with loc, iloc, Boolean Indexing
Series Home: Python + AI/ML Tutorial Series

No comment