The two survey methods — describe for what the values are, info for what the columns are.
Is this data what I think it is? That is the question every analysis rests on, and scrolling through rows is a terrible way to answer it. describe answers it in one line: for every numeric column, it returns eight numbers describing the whole column, not the top of it. info answers the companion question — how many rows, which dtypes, how many non-missing values per column, and how much memory the whole thing occupies.
I treat them as a pair, which is why they share a page here rather than getting one each. head shows you five rows that somebody else chose; describe and info show you the shape of all of them. If you only ever run head after loading a file, you are looking at a sample of size five and calling it a survey.
Official documentation: DataFrame.describe, Series.describe and DataFrame.info
The arguments that earn their keep
df.describe() # numeric columns only
df.describe(include='all') # every column, numeric and not
df.describe(include='str') # only the string columns
df.describe(percentiles=[.1, .9]) # your choice of percentiles
df.info() # dtypes, non-null counts, memory
df.info(memory_usage='deep') # memory you can actually believe
Three things about that list.
The default is numeric-only, and it is silent about what it left out. A frame of twenty columns where four are numbers gives you a four-column summary and no mention of the other sixteen.
include='str' is the Pandas 3 spelling. include=object still works and still selects string columns, but it now raises a Pandas4Warning telling you to be explicit, so write 'str' in new code.
percentiles= replaces the default 25/50/75 rather than adding to it. If you pass percentiles=[.9] you get a summary with no median in it at all — ask for 0.5 when you want one.
The eight rows
For a numeric column: count is the number of non-missing values, mean and std are the average and the sample standard deviation, min and max are the extremes, and 25%, 50%, 75% are the quartiles, with 50% being the median. That is it — eight rows, and the two that catch the most bugs are count and min/max.
For a string column you get four different rows: count, unique, top (the most common value) and freq (how often the top value appears).
A worked example, on real data
Here is a year of daily weather from Boston's Logan airport, straight from NOAA:
import pandas as pd
url = ('https://www.ncei.noaa.gov/data/global-summary-of-the-day/'
'access/2024/72509014739.csv')
cols = ['DATE', 'NAME', 'TEMP', 'VISIB', 'WDSP',
'GUST', 'MAX', 'MIN', 'PRCP', 'SNDP']
df = pd.read_csv(url, usecols=cols)
df.describe().round(1)
TEMP VISIB WDSP GUST MAX MIN PRCP SNDP
count 366.0 366.0 366.0 366.0 366.0 366.0 366.0 366.0
mean 53.5 9.3 9.2 361.2 63.5 45.3 0.1 999.9
std 16.0 1.5 3.2 463.5 17.7 15.2 0.3 0.0
min 15.8 1.3 2.7 15.0 26.1 10.0 0.0 999.9
25% 40.2 9.4 7.0 22.9 48.9 34.0 0.0 999.9
50% 52.7 10.0 8.7 28.9 63.5 45.0 0.0 999.9
75% 67.1 10.0 10.8 999.9 78.1 59.0 0.1 999.9
max 84.0 10.0 25.9 999.9 98.1 73.9 2.6 999.9
TEMP, MAX, MIN and WDSP look like Boston. Two columns do not.
The average wind gust at Logan in 2024 was 361 mph. That is not a windy year; that is a broken column. Read down the GUST column and you can see the machinery: the 75th percentile and the maximum are both exactly 999.9, which means at least a quarter of the days carry that value, and it is not a measurement. It is NOAA's code for "no gust recorded." Ask .mean() on its own and you get 361.2 with no hint that anything is wrong. describe shows you the same 361.2 sitting next to a max of 999.9, and the shape of the problem is immediately obvious.
SNDP, snow depth, is worse. Its min, its max and all three quartiles are 999.9, and its standard deviation is 0. Every single value in that column is the sentinel. The column contains no data whatsoever, and nothing about df.head() would ever have told you.
NOAA documents its sentinels, so the fix belongs at load time:
df = pd.read_csv(url, usecols=cols,
na_values={'GUST': '999.9', 'SNDP': '999.9',
'VISIB': '999.9', 'PRCP': '99.99'})
df.describe().round(1)
TEMP VISIB WDSP GUST MAX MIN PRCP SNDP
count 366.0 366.0 366.0 240.0 366.0 366.0 366.0 0.0
mean 53.5 9.3 9.2 25.8 63.5 45.3 0.1 NaN
std 16.0 1.5 3.2 6.6 17.7 15.2 0.3 NaN
min 15.8 1.3 2.7 15.0 26.1 10.0 0.0 NaN
25% 40.2 9.4 7.0 21.0 48.9 34.0 0.0 NaN
50% 52.7 10.0 8.7 25.1 63.5 45.0 0.0 NaN
75% 67.1 10.0 10.8 28.9 78.1 59.0 0.1 NaN
max 84.0 10.0 25.9 51.1 98.1 73.9 2.6 NaN
The average gust is 25.8 mph, the strongest of the year was 51.1, and — look at the top row — count is now 240 for GUST, 366 for everything else, and 0 for SNDP. Three different counts in one frame. That row is the honest answer to "how much data do I actually have," and the difference between 366 and 240 is exactly the 126 days with no gust reading.
Now the columns describe refused to mention:
df.describe(include='str')
DATE NAME
count 366 366
unique 366 1
top 2024-01-01 BOSTON LOGAN INTERNATIONAL AIRPORT, MA US
freq 1 366
Two useful facts in four rows. DATE has 366 unique values across 366 rows, so there are no duplicate days and none are missing — 2024 was a leap year, and all of it is here. NAME has exactly one unique value, repeated 366 times, which means it is a constant carrying no information at all. Constant columns are common in files assembled per station or per region, and they are worth spotting before you group by one.
Then info, which answers the questions describe does not:
df.info(memory_usage='deep')
<class 'pandas.DataFrame'>
RangeIndex: 366 entries, 0 to 365
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 DATE 366 non-null str
1 NAME 366 non-null str
2 TEMP 366 non-null float64
3 VISIB 366 non-null float64
4 WDSP 366 non-null float64
5 GUST 240 non-null float64
6 MAX 366 non-null float64
7 MIN 366 non-null float64
8 PRCP 366 non-null float64
9 SNDP 0 non-null float64
dtypes: float64(8), str(2)
memory usage: 76.3 KB
The same 366 / 240 / 0 story, plus the dtypes. DATE came in as text rather than a datetime, which is worth knowing before you try to sort by it.
I always pass memory_usage='deep', and here is why. Run the same call without it and the last line reads:
memory usage: 28.7 KB
Same frame, and the shallow number is 37 percent of the real one. When a string column is stored as pointers to Python string objects, the default calculation measures the pointers and ignores the strings. NAME reports 2,928 bytes shallow and 32,940 deep — off by a factor of eleven, because the shallow count never looks at the 41-character station name sitting behind each pointer. Pandas 2 at least flagged this by printing the total as 28.7+ KB, with a plus sign meaning "and then some." Pandas 3 prints the undercount with no marker at all.
One honest caveat: if PyArrow is installed, str columns are backed by Arrow rather than by Python objects, Pandas knows the true buffer size without walking it, and both calls report the same 47.0 KB. Passing deep costs you nothing in that case, which is the best argument for always passing it — it is either free or it is the only correct answer, and you should not have to remember which.
Four mistakes people make
Reading count as the number of rows. It is the number of non-missing values, which is why GUST shows 240 above while its neighbors show 366. Two columns of the same frame will happily report different counts, and that difference is information, not a rendering glitch. For the row count use len(df) or df.shape[0], and when the gaps matter, dropna is the next stop.
Trusting a mean without looking at min and max. A sentinel like 999.9 or -999 drags the average without ever announcing itself. df['GUST'].mean() returns 361.2 and looks like a number; the same column in describe shows 361.2 next to a maximum of 999.9 and the fraud is visible instantly. This is the single best reason to reach for describe rather than for the aggregations one at a time.
Not noticing which columns were skipped. describe quietly drops everything non-numeric, so the survey you just ran may have covered four of your twenty columns. If the answer feels thin, it is: use include='all' to see the string and numeric columns side by side, or include='str' for just the ones the default hid.
Running info without memory_usage='deep'. The default undercounts string-heavy frames badly — 28.7 KB against 76.3 KB on a small file like this one, and far worse on a big one. Type the deep every time; see also memory_usage for the per-column breakdown. One related surprise: on frames wider than 100 columns, info collapses to a one-line summary and shows you no per-column detail at all. Pass verbose=True, show_counts=True to get it back.
Where it shows up in Bamboo Weekly
Bamboo Weekly #3: Earthquakes is describe at its simplest and most effective. Filtering the USGS feed down to Turkey on February 6, 2023 and calling .describe() on the magnitude column reports nine earthquakes that day, a median of 6.0 and a maximum of 7.8 — the whole story of the event in eight numbers. Free to read.
Bamboo Weekly #61: Solar eclipse runs describe on a timedelta column, the length of totality in each US county. It works exactly as it does on numbers, printing durations rather than floats: a mean of 3 minutes 5 seconds, a minimum of 2 seconds and a maximum just under 4 minutes. Also free to read.
Bamboo Weekly #150: Kalshi uses describe the way this page argues for. Summarizing the bid-ask spread on a prediction market gives a median of 100 — meaning sellers wanted 100 and buyers offered nothing — which is obvious nonsense and sends the analysis back to filter for markets with real volume. The same post runs df.info(memory_usage='deep') on a 3.3 GB frame and cuts it by 75 percent with astype('category').
Bamboo Weekly #149: Flu season is the deep argument in full, on CDC data: 1.7 MB reported by the default, 3.1 MB by the honest count, with the Pandas 2 plus sign explained along the way.
Practice it
Work through a describe exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/describe/
Go deeper
head is the other half of this argument — the first look that should never be the only look. isna counts the gaps that count implies and dropna removes them, value_counts does for a single categorical column what describe(include='str') sketches for all of them, and astype is how you act on the dtypes info just showed you. If a column turned out to be text when it should be numbers, that argument belongs in read_csv, not in a cleanup step afterward.
More Pandas videos on Python and Pandas with Reuven Lerner.
Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.
Related methods
.isna()— when you want to count genuine missing values column by column.mean()— when you want one statistic rather than the whole survey.head()— when you want to look at actual rows rather than summary statistics.memory_usage()— when the frame is too big and you need to know which columns cost the most.corr()— when the question is how two columns relate rather than what each containspx.distributions()— when a picture of the spread says more than eight summary statistics
See it on real data
Below are the 11 Bamboo Weekly exercises that use describe on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #182: Surveillance technology
- Bamboo Weekly #178: Harmful algal bloom
- Bamboo Weekly #173: IPOs
- Bamboo Weekly #171: Hantavirus
- Bamboo Weekly #165: Artemis II
- Bamboo Weekly #150: Kalshi
- Bamboo Weekly #97: Drones
- Bamboo Weekly #87: Nuclear power
- Bamboo Weekly #86: FEMA
- Bamboo Weekly #61: Solar eclipse
- Bamboo Weekly #3: Earthquake
Part of the Pandas Methods Index. See also practice by skill.