import os
import pandas as pd
from pandas.api.types import is_string_dtype, is_numeric_dtype
import geopandas as gpd
import matplotlib.pyplot as plt
# Display all columns when looking at dataframes
pd.set_option("display.max_columns", None)12 Streamline your code
In this lesson we will learn how to extract geospatial data from a CSV to create a geopandas.GeoDataFrame, introduce more customizations for maps and matplotlib figures, and go over strategies to streamline our code.
By the end of this lesson, students will be able to:
- create a
geopandas.GeoDataFramefrom a CSV with longitude and latitude columns - format printed output using f-strings
- use
forloops, alone and withzip(), to avoid repeating code - check that their code does what they expect using
assert - create figures with multiple axes
About the data
The U.S. energy landscape relies on a mix of fossil fuels and renewables, each with unique environmental and economic impacts. As the nation works toward sustainability and energy security, understanding this energy mix is essential for informed policy and progress toward cleaner energy.
In this lesson, we will use data from the U.S. Energy Information Administration (EIA) about operable electric generating plants in the United States by energy source, as of February 2025 [1]. The dataset includes information on plant types and energy sources, offering insights into the diversity of power sources—from fossil fuels to renewables—that supply electricity nationwide. The dataset’s metadata can be accessed here. The EIA data on electric plants has been downloaded as a CSV and reprojected into the EPSG:4269 CRS for this lesson. It can be accessed here.
Each row in the data is one power plant. These are the columns we will use in this lesson, with descriptions adapted from the EIA metadata:
| Column | Description |
|---|---|
state |
State where the power plant is located. Also includes the District of Columbia and Puerto Rico |
primsource |
Primary energy source of the power plant, for example solar, natural gas or wind. It is the source with the largest net summer capacity in the plant |
install_mw |
Installed (nameplate) capacity, in megawatts (MW): the maximum output of the plant’s generators as rated by the manufacturer |
longitude, latitude |
Location of the power plant, in the NAD83 CRS (EPSG:4269) |
Additionally, we will use a TIGER shapefile of the US states from the United States Census Bureau [2]. TIGER stands for Topologically Integrated Geographic Encoding and Referencing. This used to be the data format in which the US Census distributed geospatial data, but since 2008 TIGER files are converted to shapefiles. You can view the metadata for all the TIGER shapefiles here.
Follow these steps to download the shapefile with the United States’ states:
- At the 2026 TIGER page, under Download, click on “Web Interface”
- For year, select 2026, and for layer type select “States (and equivalent)”. Click submit.
- Click on “Download national file”.
The column descriptions for the US states shapefile, from the TIGER/Line Shapefiles technical documentation, are:
| Column | Type | Description |
|---|---|---|
REGION |
String | Region code |
DIVISION |
String | Division code |
STATEFP |
String | State FIPS code |
STATENS |
String | State NS code |
GEOID |
String | State identifier; state FIPS code |
GEOIDFQ |
String | Fully qualified GEOID; used to join data tables from data.census.gov |
STUSPS |
String | Two-character United States Postal Service (USPS) state postal abbreviation |
NAME |
String | State name |
LSAD |
String | LSAD code for the state |
MTFCC |
String | MAF/TIGER Feature Class Code (G4000) |
FUNCSTAT |
String | Functional status |
ALAND |
Number | Land area |
AWATER |
Number | Water area |
INTPTLAT |
String | Latitude of the internal point |
INTPTLON |
String | Longitude of the internal point |
CSV to geopandas.GeoDataFrame
Let’s start by importing packages and updating viewing options:
Next, we import the power plants dataset. In this lesson, we read it directly from its URL.
# Import power plants data
URL = 'https://raw.githubusercontent.com/carmengg/eds-220-book/refs/heads/main/data/power_plants_epsg4269.csv'
power_plants = pd.read_csv(URL)
power_plants.head(3)| objectid | plant_code | plant_name | utility_id | utility_name | sector_name | street_address | city | county | state | zip | primsource | source_desc | tech_desc | install_mw | total_mw | bat_mw | bio_mw | coal_mw | geo_mw | hydro_mw | hydrops_mw | ng_mw | nuclear_mw | crude_mw | solar_mw | wind_mw | other_mw | source | period | longitude | latitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 62897 | 1873 | Tower | 20910 | Wolverine Power Supply Coop | Electric Utility | 9663 Cooperative Road | Tower | Cheboygan | Michigan | 49792 | petroleum | Petroleum = 19.3 MW | Petroleum Liquids | 21.3 | 19.3 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 19.3 | 0.0 | 0.0 | 0.0 | EIA-860, EIA-860M and EIA-923 | 202502 | -84.294970 | 45.362818 |
| 1 | 62898 | 1874 | Caro | 18895 | Thumb Electric Coop of Mich | Electric Utility | 2437 East Dayton Road | Caro | Tuscola | Michigan | 48723 | petroleum | Petroleum = 8.5 MW | Petroleum Liquids | 9.4 | 8.5 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 8.5 | 0.0 | 0.0 | 0.0 | EIA-860, EIA-860M and EIA-923 | 202502 | -83.327806 | 43.465899 |
| 2 | 62899 | 1875 | Ubly | 18895 | Thumb Electric Coop of Mich | Electric Utility | 4159 Ubly Road | Ubly | Huron | Michigan | 48475 | petroleum | Petroleum = 12.3 MW | Petroleum Liquids | 12.4 | 12.3 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 12.3 | 0.0 | 0.0 | 0.0 | EIA-860, EIA-860M and EIA-923 | 202502 | -82.938806 | 43.721699 |
The power plants file is a CSV. Unlike shapefiles or other geospatial file formats, a CSV doesn’t store a geometry column or a CRS, so geopandas cannot reliably build them on its own. We will need to create the geometry and set the CRS ourselves.
To do so we will use the longitude and latitude columns in the CSV. These indicate the location of the power plants in the NAD83 CRS (EPSG:4269). We can use this information to create a new geopandas.GeoDataFrame from the pandas.DataFrame using the geopandas function points_from_xy():
# Create points from latitude and longitude
points = gpd.points_from_xy(power_plants['longitude'],
power_plants['latitude'])
# Create geodataframe
power_plants = gpd.GeoDataFrame(power_plants, # Data
geometry=points, # Specify geometry column
crs='EPSG:4269' # Specify CRS
)Let’s check that we now have a geometry column:
power_plants.geometry0 POINT (-84.29497 45.36282)
1 POINT (-83.32781 43.46590)
2 POINT (-82.93881 43.72170)
3 POINT (-85.42600 43.83930)
4 POINT (-85.85510 42.73100)
...
13440 POINT (-118.10900 34.60800)
13441 POINT (-79.28172 42.50275)
13442 POINT (-77.74272 43.18914)
13443 POINT (-75.90345 42.33782)
13444 POINT (-120.63443 37.08391)
Name: geometry, Length: 13445, dtype: geometry
With the geometry column and CRS, we can plot our dataset:
power_plants.plot() 
f-strings
So far, we have printed variables by passing several arguments to the print() function. This means that we write commas between every string and variable we want to print, and then the print() function joins these into a single string, with a space between each one. For example:
print('CRS:', power_plants.crs)CRS: EPSG:4269
Another popular way of mixing strings and variables in print statements is by creating an f-string which stands for “formatted string”. The simplest syntax for an f-string is:
f" some text {replace}"where replace can be a variable, an expression, or a function or method call. For example:
# Explore CRS
print(f"ellipsoid: {power_plants.crs.ellipsoid}")
print(f"datum: {power_plants.crs.datum}")ellipsoid: GRS 1980
datum: North American Datum 1983
We just created a string replacing the value inside the curly brackets {}.
One of the advantages of using f-strings is that they offer customization for formatting the output:
# Any expression can go inside the curly brackets
# :, adds thousands separators
print(f"The data has {len(power_plants):,} power plants in {power_plants['state'].nunique()} states and territories.")
# :.2f rounds to 2 decimals
print(f"Average installed capacity: {power_plants['install_mw'].mean():.2f} MW")The data has 13,445 power plants in 52 states and territories.
Average installed capacity: 99.39 MW
Whether you use an f-string or simply concatenate strings with variables inside your print statements depends entirely on the application. For quickly checking a variable, a print statement might be enough, while using f-strings can be better to include custom messages during runtime. The best tool can be different depending on the task!
These are some good resources to learn more about f-string formatting:
Import shapefile
Let’s import the TIGER shapefile
# Import states data
fp = os.path.join('data','tl_2026_us_state','tl_2026_us_state.shp')
states = gpd.read_file(fp)
# Simplify column names
states.columns = states.columns.str.lower()
states.head(3)| region | division | statefp | statens | geoid | geoidfq | stusps | name | lsad | mtfcc | funcstat | aland | awater | intptlat | intptlon | geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 36 | 01779796 | 36 | 0400000US36 | NY | New York | 00 | G4000 | A | 122049230845 | 19256680224 | +42.9133974 | -075.5962723 | MULTIPOLYGON (((-74.04075 40.70017, -74.04073 ... |
| 1 | 4 | 8 | 32 | 01779793 | 32 | 0400000US32 | NV | Nevada | 00 | G4000 | A | 284537080522 | 1839846018 | +39.3310928 | -116.6151469 | POLYGON ((-119.32418 41.99392, -119.32362 41.9... |
| 2 | 4 | 9 | 02 | 01785533 | 02 | 0400000US02 | AK | Alaska | 00 | G4000 | A | 1479942808337 | 244276695297 | +63.3469818 | -152.8385657 | MULTIPOLYGON (((-167.55823 60.22436, -167.5567... |
and obtain some preliminary geospatial information about the states geodataframe:
print(states.crs)
states.plot()EPSG:4269

for loops
The region, division, and statefp columns contain codes. The US Census Bureau groups the states into four regions (for example, the West), which are further split into nine divisions (for example, the Pacific division). Each state also has a FIPS (Federal Information Processing Standards) code, a numeric code that identifies it. For example, California’s FIPS code is 06. We will use these codes to select the states we need.
It can be easier to work with the codes as numbers instead of strings, so let’s update the corresponding columns in the states geodataframe. We start by checking the data type of the region, division, and statefp columns:
code_cols = ['region', 'division', 'statefp']
# Check whether codes columns are strings
for column in code_cols:
print(f"{column} is string dtype? {is_string_dtype(states[column])}")region is string dtype? True
division is string dtype? True
statefp is string dtype? True
Remember for loops execute a block of code a fixed number of times, iterating over a set of objects. In this case, we iterate over the list of column names code_cols = ['region', 'division', 'statefp'].
We could have checked whether all the region, division, and statefp columns were of string data type by using the following code:
print(f"region is string dtype? {is_string_dtype(states['region'])}")
print(f"division is string dtype? {is_string_dtype(states['division'])}")
print(f"statefp is string dtype? {is_string_dtype(states['statefp'])}")However, this is inconvenient as it repeats the same pieces of code, only changing the column name. Instead, using the for loop allows us to succinctly print the same information:
code_cols = ['region', 'division', 'statefp']
for column in code_cols:
print(f"{column} is string dtype? {is_string_dtype(states[column])}")Don’t Repeat Yourself (DRY) is a core programming principle that encourages reducing redundancy and consolidating repeated logic. Try implementing it as much as possible! If you need to repeat the “same” code more than twice, you likely need a for loop.
assert
Next, we update the data type of the code columns to be integers. This time, we check the data type of the column using the is_numeric_dtype() function inside an assert statement:
# Update code columns into integers
for column in code_cols:
states[column] = states[column].astype('int')
assert is_numeric_dtype(states[column]) # Check conversionThe assert keyword does nothing if the expression next to it evaluates to True. If it evaluates to False, it raises an AssertionError exception and stops your code from running any further. For example,
# Does nothing if statement is True
assert 2+2 == 4
# Raises an error if statement is False
assert 2+2 == 3--------------------------------------------------------------------------- AssertionError Traceback (most recent call last) Cell In[13], line 5 2 assert 2+2 == 4 4 # Raises an error if statement is False ----> 5 assert 2+2 == 3 AssertionError:
In our data type conversion code, since no AssertionError was raised, we can be confident that the data type was updated.
Data selection
For this lesson, we want to use only the contiguous states. As seen in the plot, the data covers a bigger area.
From the TIGER shapefiles metadata we know that:
In addition to the fifty states, the Census Bureau treats the District of Columbia, Puerto Rico, and the Island Areas (American Samoa, the Commonwealth of the Northern Mariana Islands, Guam, and the U.S. Virgin Islands) as statistical equivalents of states for the purpose of data presentation.
In this US Census Bureau file we can see what each code for the region, division, and state corresponds to.
- What are the unique values for region, division, or state codes in the data?
- Which codes should we select to keep only states in the contiguous US?
Let’s go ahead and select the data:
# Select contiguous US states
contiguous = states[(states['region']!=9) & (~states['statefp'].isin([2,15]))]In this code we used the syntax
~df['column'].isin([val1, val2, val3])The ~ tilde symbol is used in Python to negate a statement. So the previous line could be read as “the values in df’s column which are not in the list [val1, val2, val3].”
Select the data in the power_plants data frame for the contiguous US states.
Let’s check our answer:
# Select power plants in the contiguous US states
power_plants = power_plants[~power_plants['state'].isin(['Puerto Rico','Hawaii','Alaska'])]Plotting
Before we plot our data, let’s make sure they are in the same CRS:
assert contiguous.crs == power_plants.crsCode
fig, ax = plt.subplots(figsize=(9, 5)) # Update figure size
# Remove the axis for a cleaner map
ax.axis('off')
# Title for the plot
ax.set_title('Operable electric generating plants in the contiguous United States',
fontsize=15)
# Add states
contiguous.plot(ax=ax,
color='none',
edgecolor='#362312')
# Add electric power plants colored by energy source
power_plants.plot(ax=ax,
column='primsource',
legend=True,
markersize=4,
cmap='tab20',
alpha=0.5,
legend_kwds={
'title': 'Primary energy source',
'title_fontsize': 'small',
'fontsize': 'small',
'loc': 'upper left',
'bbox_to_anchor': (0, 0),
'ncols': 6
})
plt.show()
In the map above we specified the figure size when creating the plot. This size is given in inches, but can be updated to other units (pixels, cm, etc).
We also controlled the legend location using loc and bbox_to_anchor in the legend_kwds:
locindicates the corner of the legend we want to use for placement, andbbox_to_anchoris a tuple with coordinates indicating where to place the corner specified inlocrelative to the axes. Values between 0 and 1 are within the axes.
matplotlib uses a variety of ways to locate elements within the graph and it is best to check the documentation to not spend too much time fiddling with locations.
for with zip
Often, we need to iterate simultaneously over two lists (or other iterables). The zip() function in Python allows you to combine two or more lists (or other iterables) so that you can iterate over their elements in pairs. When used with a for loop, it lets you process elements from each list together, like this example:
# Iterate over a single list
numbers = [1, 2, 3]
for num in numbers:
print(num)
print('\n') # Blank line
# Iterate over two lists in pairs using zip()
letters = ['a', 'b', 'c']
for num, letter in zip(numbers, letters):
print(num, letter)1
2
3
1 a
2 b
3 c
Let’s see a practical application of for loops and zip() with matplotlib subplots. A common situation when code gets repeated is when creating subplots. For example:
fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(7, 3))
axes[0].set_title('This is axis 0')
axes[1].set_title('This is axis 1')
axes[2].set_title('This is axis 2')
plt.show()
In this example, notice that the axes variable returned by the plt.subplots() function is actually an array of axes we can iterate over. Remember that the figure and the axes are separate elements in a matplotlib plot.

Use for and zip() to create the same subplots and avoid redundancy.
Select the power plants in California in a variable named
ca_power_plants.Create a list named
top_sourceswith California’s top 3 primary sources by number of power plants. That is, the 3 values ofprimsourcethat appear in the most California power plants.Isolate the California state boundary in a variable named
ca_boundary.Recreate the following plot:
