Code
import pandas as pd
url = 'https://eds-217-essential-python.github.io/data/openaq_goleta_measurments.csv'
goleta = pd.read_csv(url)
goleta.shape(1962, 15)
🔍 The Filter Pattern

A panda at a farmers market, choosing his vegetables. MidJourney 5
Earlier this morning you wrote a question and asked it of an entire column of the Toolik weather table. The answer came back as a boolean mask: a True or a False for every one of its 11,171 rows.
A mask on its own does not do much for us. The rows it says True about are what you are after, and getting them takes one line of code, which we will write in nearly every session from here on.
By the end of this session you will be able to:
df[df['col'] > value], without looking it up& and |, and get the parentheses right~.isin().copy() is for, and use it when you intend to keep a resultCreate the file. In the Explorer, hover over the EDS217 heading and click New File…, then type the name in full, extension included: Session_3B_Filtering_Data.ipynb
Check the kernel. The Kernel Selector in the notebook’s action bar should read Python 3.11.15 (Conda: eds217). If it reads anything else, click it, choose Change Kernel, and pick the eds217 entry.
Add a title cell. Click + Markdown in the action bar, and give it this content, with today’s date:
# Day 3: Session 3B - The Filter Pattern
[Session Webpage](https://eds-217-essential-python.github.io/course-materials/interactive-sessions/3b_filtering_data.html)
Date: 09/02/2026Save with Ctrl + S (Cmd + S on macOS), and keep saving as you go.
Read in the Goleta air quality data you explored yesterday:
Here it is, whole:
| location_id | location_name | parameter | value | unit | datetimeUtc | datetimeLocal | timezone | latitude | longitude | country_iso | isMobile | isMonitor | owner_name | provider | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 720 | 1186 | Goleta | pm10 | 21.0 | µg/m³ | 2024-07-17T14:00:00+00:00 | 2024-07-17T07:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 746 | 1186 | Goleta | pm10 | 21.0 | µg/m³ | 2024-07-22T20:00:00+00:00 | 2024-07-22T13:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 795 | 1186 | Goleta | pm10 | 38.0 | µg/m³ | 2024-07-24T21:00:00+00:00 | 2024-07-24T14:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 796 | 1186 | Goleta | pm10 | 36.0 | µg/m³ | 2024-07-24T22:00:00+00:00 | 2024-07-24T15:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 797 | 1186 | Goleta | pm10 | 23.0 | µg/m³ | 2024-07-24T23:00:00+00:00 | 2024-07-24T16:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 1226 | 1186 | Goleta | pm10 | 27.0 | µg/m³ | 2024-08-11T23:00:00+00:00 | 2024-08-11T16:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1227 | 1186 | Goleta | pm10 | 23.0 | µg/m³ | 2024-08-12T00:00:00+00:00 | 2024-08-11T17:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1376 | 1186 | Goleta | pm25 | 22.0 | µg/m³ | 2024-07-18T08:00:00+00:00 | 2024-07-18T01:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1712 | 1186 | Goleta | pm25 | 22.0 | µg/m³ | 2024-08-01T08:00:00+00:00 | 2024-08-01T01:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1873 | 1186 | Goleta | pm25 | 21.0 | µg/m³ | 2024-08-08T08:00:00+00:00 | 2024-08-08T01:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
115 rows × 15 columns
Read it from the inside out. goleta['value'] > 20 is the same kind of mask you wrote this morning, on a different table: True on every row where the value is above 20, False everywhere else. Wrapping it in goleta[ ... ] says “give me the rows where that was True.”
The line we just ran is the filter pattern. The general form is:
The df appears twice, which looks redundant until you see what each one is doing. The inner one builds the question, the outer one applies the answer, and they are the same table both times.
Give the result a name if you want to use it later:
115 rows out of 1,962. Compare the shapes before and after, and you can see exactly how many rows the filter kept:
before: (1962, 15)
after: (115, 15)
🐍 Filtering never changes the original. goleta still has all 1,962 rows. You get back a new, smaller table, and if you don’t give it a name it is displayed once and then discarded. .rename() followed the same rule yesterday.
Filter goleta to the rows where value is below zero. How many are there? A concentration below zero is not physically possible, so make a note of what you find; we will deal with values like these on Day 4.
The same pattern works on text columns. Use == for an exact match:
| location_id | location_name | parameter | value | unit | datetimeUtc | datetimeLocal | timezone | latitude | longitude | country_iso | isMobile | isMonitor | owner_name | provider | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1186 | Goleta | o3 | 0.025 | ppm | 2024-07-12T01:00:00+00:00 | 2024-07-11T18:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1 | 1186 | Goleta | o3 | 0.028 | ppm | 2024-07-12T02:00:00+00:00 | 2024-07-11T19:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 2 | 1186 | Goleta | o3 | 0.029 | ppm | 2024-07-12T03:00:00+00:00 | 2024-07-11T20:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 3 | 1186 | Goleta | o3 | 0.027 | ppm | 2024-07-12T04:00:00+00:00 | 2024-07-11T21:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 4 | 1186 | Goleta | o3 | 0.026 | ppm | 2024-07-12T05:00:00+00:00 | 2024-07-11T22:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
711 ozone readings. Text comparisons are exact and case-sensitive, so 'O3' would have matched nothing at all:
An empty result is not an error. Zero rows is a real answer, and it usually means a spelling or capitalization problem rather than a fact about the data.
Filter goleta to the pm25 readings and store them in a variable called pm25. How many rows? Check your number against goleta['parameter'].value_counts() from yesterday.
Yesterday you found that goleta['value'] stacks three different pollutants, in two different units, in one column. Mixing three pollutants together made .describe() on the whole column meaningless.
Filtering is the fix:
count 1962.000000
mean 6.378173
std 7.313763
min -4.000000
25% 0.027000
50% 5.000000
75% 10.000000
max 40.000000
Name: value, dtype: float64
count 734.000000
mean 6.480926
std 3.651091
min -4.000000
25% 4.000000
50% 6.000000
75% 8.000000
max 22.000000
Name: value, dtype: float64
The first summary averages micrograms per cubic metre together with parts per million, which gives a mean that describes no substance at all. The second one describes 734 particulate readings, all in µg/m³, and only the second one tells you anything about the air in Goleta.
& means “and”. Every comparison gets its own parentheses:
| location_id | location_name | parameter | value | unit | datetimeUtc | datetimeLocal | timezone | latitude | longitude | country_iso | isMobile | isMonitor | owner_name | provider | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1261 | 1186 | Goleta | pm25 | 14.0 | µg/m³ | 2024-07-13T10:00:00+00:00 | 2024-07-13T03:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1265 | 1186 | Goleta | pm25 | 10.0 | µg/m³ | 2024-07-13T14:00:00+00:00 | 2024-07-13T07:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1270 | 1186 | Goleta | pm25 | 10.0 | µg/m³ | 2024-07-13T19:00:00+00:00 | 2024-07-13T12:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1313 | 1186 | Goleta | pm25 | 12.0 | µg/m³ | 2024-07-15T14:00:00+00:00 | 2024-07-15T07:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1314 | 1186 | Goleta | pm25 | 11.0 | µg/m³ | 2024-07-15T15:00:00+00:00 | 2024-07-15T08:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 1953 | 1186 | Goleta | pm25 | 16.0 | µg/m³ | 2024-08-11T16:00:00+00:00 | 2024-08-11T09:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1954 | 1186 | Goleta | pm25 | 16.0 | µg/m³ | 2024-08-11T17:00:00+00:00 | 2024-08-11T10:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1955 | 1186 | Goleta | pm25 | 13.0 | µg/m³ | 2024-08-11T18:00:00+00:00 | 2024-08-11T11:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1956 | 1186 | Goleta | pm25 | 14.0 | µg/m³ | 2024-08-11T19:00:00+00:00 | 2024-08-11T12:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1957 | 1186 | Goleta | pm25 | 10.0 | µg/m³ | 2024-08-11T20:00:00+00:00 | 2024-08-11T13:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
128 rows × 15 columns
128 hours where fine particulate matter was above 9 µg/m³, which is the annual air quality standard the US EPA set in 2024, tightened from the 12 µg/m³ that had stood since 2012.
| means “or”:
(1251, 15)
Each side of an & or a | can itself be a two-part condition. When it is, wrap that whole side in a further pair of parentheses so the two questions stay separate:
(240, 15)
240 rows: 128 hours of pm25 above 9, plus 112 hours of pm10 above 20. Read the line from the inside out. Each innermost pair is one comparison, each middle pair is one complete question about one pollutant, and the | joins the two questions.
Leave them out and you get an error that has nothing to do with what you meant:
& binds more tightly than == and >, so Python combines the wrong pieces before it ever gets to your comparison.
The rule that avoids all of this: put parentheses around each comparison you join with & or |. A comparison is something with ==, >, <, >=, <= or != in it. A method call like .isin([...]) is not a comparison and does not need its own pair, though adding them does no harm if you would rather not think about which is which.
Find the ozone readings above 0.04 ppm. Then, using the two-level shape above, find the readings that are either ozone above 0.04 ppm or pm25 above 20 µg/m³. How many rows does each give you?
~~ inverts a mask: every True becomes False and every False becomes True.
1,251 rows, which is 1,962 minus the 711 ozone readings. You could have written != here and gotten the same answer:
~ is most useful when the condition is more complicated than a single comparison, and you would rather say “not that” than rewrite the whole condition backwards.
.isin()Writing (col == 'a') | (col == 'b') | (col == 'c') gets tedious fast. .isin() takes a list and matches any of its members:
(1251, 15)
Same 1,251 rows as the | version above, in a line that stays readable when the list grows.
The argument is an ordinary Python list, so you can build it first and pass it by name:
🐍 Call .isin() on the column, not on the DataFrame: df['col'].isin([...]). It returns a mask, exactly like a comparison does, so it goes in the same place inside the brackets.
Build a list called keep_parameters holding 'o3' and 'pm10', then use .isin() to filter goleta down to those two pollutants. Check your row count against the two numbers in goleta['parameter'].value_counts(); they should add up.
.copy()Once you have a subset you like, you will usually want to keep working on it, and often that means adding a column to it later this week.
When you plan to keep and modify a filtered result, say .copy():
.copy() gives you a table of your own, with no remaining connection to goleta. Without it, the subset you get back may still be tied to the original table. When you then try to change the subset, you get either a warning that is hard to interpret or a change that silently never happens.
The habit is easy to state:
If the filtered result is going to be a thing you keep and change, end the line with
.copy(). If you are just looking at it, don’t bother.
| location_id | location_name | parameter | value | unit | datetimeUtc | datetimeLocal | timezone | latitude | longitude | country_iso | isMobile | isMonitor | owner_name | provider | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1228 | 1186 | Goleta | pm25 | 3.0 | µg/m³ | 2024-07-12T01:00:00+00:00 | 2024-07-11T18:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1229 | 1186 | Goleta | pm25 | 8.0 | µg/m³ | 2024-07-12T02:00:00+00:00 | 2024-07-11T19:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1230 | 1186 | Goleta | pm25 | 6.0 | µg/m³ | 2024-07-12T03:00:00+00:00 | 2024-07-11T20:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1231 | 1186 | Goleta | pm25 | 4.0 | µg/m³ | 2024-07-12T04:00:00+00:00 | 2024-07-11T21:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
| 1232 | 1186 | Goleta | pm25 | 9.0 | µg/m³ | 2024-07-12T05:00:00+00:00 | 2024-07-11T22:00:00-07:00 | America/Los_Angeles | 34.445301 | -119.827797 | NaN | NaN | NaN | Unknown Governmental Organization | AirNow |
SettingWithCopyWarning shows up when a change you have written could apply either to the subset or to the original table, and the code as written does not say which one you meant. It is not always a real problem, but it is always worth reading! Adding .copy() at the point where you made the subset is usually the fix.
Make a .copy() of the ozone readings called ozone, then confirm with .shape and .head() that you have what you expect. Keep it in your notebook; you’ll use it in the next session.
A real filter is usually two or three of these ideas in one line:
(246, 15)
| parameter | value | unit | datetimeLocal | |
|---|---|---|---|---|
| 711 | pm10 | 20.0 | µg/m³ | 2024-07-16T22:00:00-07:00 |
| 720 | pm10 | 21.0 | µg/m³ | 2024-07-17T07:00:00-07:00 |
| 721 | pm10 | 20.0 | µg/m³ | 2024-07-17T08:00:00-07:00 |
| 746 | pm10 | 21.0 | µg/m³ | 2024-07-22T13:00:00-07:00 |
| 748 | pm10 | 16.0 | µg/m³ | 2024-07-22T15:00:00-07:00 |
Notice that the filter is broken across several lines inside the square brackets. Python allows a line break anywhere inside a pair of brackets, and a long filter is much easier to read that way.
df[df['col'] <comparison> value]. The inner df builds the mask; the outer one applies it.== matches text exactly, and is case-sensitive. An empty result is an answer, not an error.& for and, | for or, ~ for not. Every comparison needs its own parentheses.df['col'].isin([...]) matches any value in a list, and replaces a pile of |..copy() when you plan to keep the result and change it later.