Government, History, Maps

The 60th Anniversary of the 1964 Civil Rights Act

Today marks 60 years since the Civil Rights Act of 1964 was signed into law. I’ll do a quick dive into the bill’s history and then create a map of Congress’s final vote. If you’d rather skip to the code, click one of the links below.

  1. Background.
  2. Prepare the data.
  3. Plot the data.
  4. The output.

1. Background.

The bill was originally proposed by President John F. Kennedy in June 1963. JFK had won the presidency with a coalition that included 70% of Black voters but also a majority of Southern segregationists—a group deeply opposed to civil rights legislation. Kennedy spent his first two years as president triangulating on the issue. But by 1963, shifting public opinion and activism like the March on Washington for Jobs and Freedom (pictured above) made it clear to Kennedy that it was time to act. In an address to Congress, he asserted that America must fight for the liberty of all citizens “above all, because it is right.” While it was still a political calculation, Kennedy had begun making a moral argument for change.

President Kennedy with civil rights leaders (1963).

Kennedy made modest progress in lobbying members of Congress. However, he faced fierce pushback from others, including staunchly conservative Rep. Howard Smith (VA-08). Smith held the powerful position of House Rules Committee chairman. His position meant he could hold the bill in committee and deny it a floor vote regardless of the bill’s overall support. And that’s exactly what he promised to do.

Kennedy’s bill floundered in the House of Representatives for months until his assassination on November 22, 1963. But progress would accelerate from there. Vice President Lyndon B. Johnson was sworn in and he immediately made a push to pass the bill. In a joint session of Congress less than a week later, Johnson proposed:

“No memorial oration or eulogy could more eloquently honor President Kennedy’s memory than the earliest possible passage of the civil rights bill for which he fought so long.”
President Johnson; November 27, 1963

LBJ was an incredibly talented legislator—widely considered one of the best of the 20th century. His skill and bully pulpit combined with Washington’s newfound appetite for change would prove to be very effective.

But arguably the most important factor in the bill’s passage was its popularity with voters. A Gallup poll found that 59% approved of the bill while 31% opposed it. By late 1963, members of the House were circulating a discharge petition, which would have pried the bill from Rep. Smith’s hands and forced it to the floor. At long last, Smith gave in to the pressure and surrendered, and the bill passed the House on February 10, 1964.

Of course legislation isn’t that easy. The bill went to the Senate and was then filibustered for months by Southern Democrats. President Johnson, civil rights leaders, and the public all attempted to persuade senators but no progress was made. The leader of the obstructionist group, Sen. Richard Russell of Georgia, warned:

“We will resist to the bitter end any measure or any movement which would tend to bring about social equality and intermingling and amalgamation of the races in our [Southern] states.”
Sen. Richard Russell

President Johnson confronts Sen. Russell.

After four months of inaction, a pragmatic group of senators introduced a new, slightly weaker bill they believed could get enough votes (67) to overcome a filibuster. On June 19 it passed the Senate 71-29 and the Civil Rights Act of 1964 was nearly at the finish line.

Congress formed a bicameral conference committee to reconcile small differences between House and Senate versions of the bill. Two weeks later, on July 2nd, the final bill passed both chambers and President Johnson signed it into law.

President Johnson signs the bill into law (July 2, 1964).

The Civil Rights Act of 1964 has 11 sections, or “titles.” Most notably they address voting rights, segregation of schools, and discrimination by businesses, employers, and public institutions. Johnson made the case to the American people that equality under the law, guaranteed by the U.S. Constitution, was not being carried out in practice. The bill was necessary to ensure that we lived up to our stated ideals. It wasn’t about favoring any particular group of people. It was about correcting favoritism that unfortunately already existed.


“The purpose of the law is simple. It does not restrict the freedom of any American, so long as he respects the rights of others.

It does not give special treatment to any citizen.

It does say the only limit to a man’s hope for happiness, and for the future of his children, shall be his own ability.

It does say that those who are equal before God shall now also be equal in the polling booths, in the classrooms, in the factories, and in hotels, restaurants, movie theaters, and other places that provide service to the public.

I am taking steps to implement the law under my constitutional obligation to ‘take care that the laws are faithfully executed.'”

President Johnson; July 2, 1964


So what’s the point of plotting the vote? It’s a reminder that social progress isn’t done by unanimous consent. There will always be pushback.

Most of us today can agree that the Civil Rights Act of 1964 was a good thing, but it’s easy to forget how monumental a change it was. Huge swaths of the country were violently opposed. In the following years American politics saw a fundamental realignment of the major party voting coalitions.

Let me be clear: It isn’t my intention to demonize any area of the country or any political party. At least none in 2024. But I do believe that visualizing the vote adds useful context as we remember the bill that passed 60 years ago today.


2. Prepare the data.

We’ll use two files to create a map of the vote:

  • A CSV file that lists each House member’s district, political party, and vote.
  • A shapefile of 1964 US congressional districts.

Start by reading vote data into a pandas DataFrame and take a first look.

import pandas as pd

df = pd.read_csv("vote_data.csv")

print(df.head())
print(df['party'].value_counts())
print(df['vote'].value_counts())

The output is below. You can see that all 432 representatives are either Democrats or Republicans. We don’t have to worry about color-coding third parties. And the final vote was 289-126 with 20 miscellaneous non-votes, which for our purposes can be treated the same.

         state  district                                  name       party vote
0     Michigan        15   Rep. John D. Dingell [D, 1955-1964]    Democrat  Yea
1   California        29   Rep. George E. Brown [D, 1963-1970]    Democrat  Yea
2     Virginia         4    Rep. Watkins Abbitt [D, 1947-1972]    Democrat  Nay
3         Ohio        10       Rep. Homer Abele [R, 1963-1964]  Republican  Yea
4  Mississippi         1  Rep. Thomas Abernethy [D, 1953-1972]    Democrat  Nay

party
Democrat 254
Republican 178
Name: count, dtype: int64

vote
Yea 289
Nay 126
Not Voting 12
Present 4
Vacant 3
Speaker 1
Name: count, dtype: int64

My plan is to color each legislative district based on party and vote. We have two categories, each with two possible values (ignoring the 20 non-votes), so we’ll need four colors:

  • Democrat | Yes — #0044C9
  • Democrat | No — #B1B1FF
  • Republican | Yes — #E81B23
  • Republican | No — #FF9999

I think it makes sense to use the modern red/blue color scheme even though it wasn’t yet in use 60 years ago. It should help viewers to quickly understand what’s being communicated. I’ll use brighter, more saturated colors for Yes votes and muted pastels for No.

Create a new column to hold color data. Later we’ll pass it as an argument to the plot method.

def get_color(row):
    vote_type = (row['party'], row['vote'])

    color_dict = {("Democrat", "Yea"): "#0044C9",
                  ("Democrat", "Nay"): "#B1B1FF",
                  ("Republican", "Yea"): "#E81B23",
                  ("Republican", "Nay"): "#FF9999"}

    if vote_type in color_dict:
        return color_dict[vote_type]
    else:
        return "#F7F7CC"


df.loc[:, 'color'] = df.apply(get_color, axis=1)

This DataFrame will be combined with a shapefile GeoDataFrame. We’ll need an identical column in both that can be used to match rows. Both files have state and district number columns so let’s concatenate them and call it district_id.

df.loc[:, 'district_id'] = df['state'] + df['district'].astype(str)

Read the shapefile into a GeoDataFrame and create a corresponding district_id column. The geopandas code is essentially the same.

import geopandas as gpd

gdf = gpd.read_file("shapefile/districts088.shp", epsg=4326)

gdf.loc[:, 'district_id'] = gdf['STATENAME'] + gdf['DISTRICT'].astype(str)

Now we can join the data. Call merge on gdf and reassign the result back to gdf. Specify that district_id is the column in common.

If you check the number of columns you’ll find that df has 7, gdf has 17, and the resulting merged gdf has 23. That means our operation worked correctly.

gdf = gdf.merge(df, on="district_id")

3. Plot the data.

Now we can map the data using Matplotlib. I’ll use a custom mplstyle to help cut down on repeated code. It will be linked at the bottom of this post.

from mpl_toolkits.axes_grid1.inset_locator import zoomed_inset_axes
from matplotlib.patches import Rectangle

plt.style.use("civil_rights_act.mplstyle")

fig, ax = plt.subplots()

Both pandas and geopandas make it easy to plot dataframes. Call the plot() method and specify on which axes object it should be drawn.

categorical=True tells geopandas that we’re plotting categories rather than numerical data. We have five discrete categories (e.g. Democrat | Yes) and we don’t need a continuous color gradient. We’ll specify our own colors by passing the color column. edgecolor and linewidth define borders between congressional districts.

x-limits and y-limits are in units of longitude and latitude, respectively.

gdf.plot(ax=ax, categorical=True, color=gdf['color'], edgecolor="black", linewidth=0.2)

ax.set_xlim(-125.1, -66.6)
ax.set_ylim(24.4, 49.5)

Next add insets for Alaska and Hawaii. Create a view of gdf containing only the state’s data and an axes object on which to draw it.

x and y limits are expressed as longitude and latitude, just like above, but bbox coordinates range from 0 to 1 across the parent axes.

It takes a few lines of code to add inset axes but it’s essentially the same process as before.

gdf_alaska = gdf[gdf['state'] == "Alaska"]
ax_alaska = zoomed_inset_axes(ax, zoom=0.3, bbox_to_anchor=(0.25, 0.29), bbox_transform=plt.gcf().transFigure)
gdf_alaska.plot(ax=ax_alaska, categorical=True, color=gdf_alaska['color'], edgecolor="black", linewidth=0.2)
ax_alaska.set_xlim(-172.5, -128)
ax_alaska.set_ylim(52, 73)

gdf_hawaii = gdf[gdf['state'] == "Hawaii"]
ax_hawaii = zoomed_inset_axes(ax, zoom=1.0, bbox_to_anchor=(0.35, 0.22), bbox_transform=plt.gcf().transFigure)
gdf_hawaii.plot(ax=ax_hawaii, categorical=True, color=gdf_hawaii['color'], edgecolor="black", linewidth=0.2)
ax_hawaii.set_xlim(-160.5, -154.5)
ax_hawaii.set_ylim(18.5, 22.5)

Next up is a legend, which I’d like to create manually using Matplotlib’s Rectangle patch.

A for loop helps to avoid repeated code. Organize legend handles and colors into a list of tuples and iterate through them.

We’re once again working on the main axes object so normal rules of latitude and longitude apply. Alaska and Hawaii are located in the lower-left corner so let’s draw the legend somewhere in the Atlantic Ocean.

legend_info = [("R Yes  (136)", "#E81B23"),
               ("R No  (35)", "#FF9999"),
               ("D Yes  (153)", "#0044C9"),
               ("D No  (91)", "#B1B1FF"),
               ("N/A  (20)", "#F7F7CC")]
y_pos = 31
for item in legend_info:
    ax.add_patch(Rectangle((-75.5, y_pos), 1.5, 1, facecolor=item[1]))
    ax.text(-73.5, y_pos + 0.5, item[0], ha="left", va="center")
    y_pos -= 1

Also make a note of the final vote tally using text. This can go north of the legend.

ax.text(-71, 37.0, "289 - 126", size=14, ha="center", va="bottom")
ax.text(-71, 36.8, "Pass", ha="center", va="top")

Finally, set a title, turn off axis clutter, and save the figure. The shapefile has a lot of fine detail so I would suggest using a relatively high dpi. Or you could save as an SVG.

ax.text(-77.5, 48.0, "Civil Rights Act of 1964", size=16, ha="center", va="center")
ax.text(-77.5, 47.2, "House of Representatives", size=14, ha="center", va="center")
ax.text(-77.5, 46.4, "July 2, 1964", size=14, ha="center", va="center")

ax.set_axis_off()
ax_alaska.set_axis_off()
ax_hawaii.set_axis_off()

plt.savefig("civil_rights_act.png", dpi=300)

4. The output.

The map confirms what we already knew: most of the bill’s opposition came from the South.

But there were exceptions all over the country. Southern Democrats from more urban districts surrounding San Antonio, Houston, Nashville, Atlanta, and Miami voted Yes. Some northern rural Republicans voted No.

As I said above, the lack of unanimity is the point. Even very good ideas face pushback. The important thing is to recognize when it’s worth making a stand.


Download the data.

Download congressional district shapefiles (ucla.edu).

Vote info (govtrack.us).

Full code:

import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1.inset_locator import zoomed_inset_axes
from matplotlib.patches import Rectangle


def get_color(row):
    vote_type = (row['party'], row['vote'])

    color_dict = {("Democrat", "Yea"): "#0044C9",
                  ("Democrat", "Nay"): "#B1B1FF",
                  ("Republican", "Yea"): "#E81B23",
                  ("Republican", "Nay"): "#FF9999"}

    if vote_type in color_dict:
        return color_dict[vote_type]
    else:
        return "#F7F7CC"


df = pd.read_csv("vote_data.csv")
df.loc[:, 'color'] = df.apply(get_color, axis=1)
df.loc[:, 'district_id'] = df['state'] + df['district'].astype(str)

gdf = gpd.read_file("shapefile/districts088.shp", epsg=4326)
gdf.loc[:, 'district_id'] = gdf['STATENAME'] + gdf['DISTRICT'].astype(str)

gdf = gdf.merge(df, on="district_id")

plt.style.use("civil_rights_act.mplstyle")
fig, ax = plt.subplots()

gdf.plot(ax=ax, categorical=True, color=gdf['color'], edgecolor="black", linewidth=0.2)

ax.set_xlim(-125.1, -66.6)
ax.set_ylim(24.4, 49.5)

gdf_alaska = gdf[gdf['state'] == "Alaska"]
ax_alaska = zoomed_inset_axes(ax, zoom=0.3, bbox_to_anchor=(0.25, 0.29), bbox_transform=plt.gcf().transFigure)
gdf_alaska.plot(ax=ax_alaska, categorical=True, color=gdf_alaska['color'], edgecolor="black", linewidth=0.2)
ax_alaska.set_xlim(-172.5, -128)
ax_alaska.set_ylim(52, 73)

gdf_hawaii = gdf[gdf['state'] == "Hawaii"]
ax_hawaii = zoomed_inset_axes(ax, zoom=1.0, bbox_to_anchor=(0.35, 0.22), bbox_transform=plt.gcf().transFigure)
gdf_hawaii.plot(ax=ax_hawaii, categorical=True, color=gdf_hawaii['color'], edgecolor="black", linewidth=0.2)
ax_hawaii.set_xlim(-160.5, -154.5)
ax_hawaii.set_ylim(18.5, 22.5)

legend_info = [("R Yes  (136)", "#E81B23"),
               ("R No  (35)", "#FF9999"),
               ("D Yes  (153)", "#0044C9"),
               ("D No  (91)", "#B1B1FF"),
               ("N/A  (20)", "#F7F7CC")]
y_pos = 31
for item in legend_info:
    ax.add_patch(Rectangle((-75.5, y_pos), 1.5, 1, facecolor=item[1]))
    ax.text(-73.5, y_pos + 0.5, item[0], ha="left", va="center")
    y_pos -= 1

ax.text(-71, 37.0, "289 - 126", size=14, ha="center", va="bottom")
ax.text(-71, 36.8, "Pass", ha="center", va="top")

ax.text(-77.5, 48.0, "Civil Rights Act of 1964", size=16, ha="center", va="center")
ax.text(-77.5, 47.2, "House of Representatives", size=14, ha="center", va="center")
ax.text(-77.5, 46.4, "July 2, 1964", size=14, ha="center", va="center")

ax.set_axis_off()
ax_alaska.set_axis_off()
ax_hawaii.set_axis_off()

plt.savefig("civil_rights_act.png", dpi=300)