Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| sidebar_position: 10 | |
| # Working with Open States Legislative Data | |
| Complete guide to using the Open States PostgreSQL dump downloaded from Plural Policy. | |
| ## Scope & limitations (important) | |
| Open States focuses on **state legislative data** β lawmakers' voting records, committee assignments, bill status, sponsors, and bill text β aggregated into a unified, machine-readable format across **all 50 states**, plus **Washington, D.C.** and **Puerto Rico**. | |
| It **does not** cover local government offices or elections. In particular, you should **not** expect Open States to have data on: | |
| - **County commissioners** | |
| - **County elections** | |
| - **School boards** | |
| - **Municipal offices** | |
| Open States was built specifically for **state legislatures**. The project was adopted by **Plural Policy** in 2021 and is now accessible via [Plural Policy](https://www.pluralpolicy.com/), which adds AI-powered tracking on top of the original dataset while staying in the state-legislative lane. | |
| For **county/local ballot information**, good starting points are: | |
| - [Ballotpedia](https://ballotpedia.org/) | |
| - [VOTE411](https://www.vote411.org/) | |
| - Directly from **county election administrator** websites | |
| ## οΏ½ Quick Start (Complete Setup) | |
| **Prerequisites:** | |
| - PostgreSQL 15+ installed ([Download](https://www.postgresql.org/download/)) | |
| - Python 3.12+ with venv | |
| - ~15 GB free disk space | |
| **Full Setup (20-30 minutes):** | |
| ```bash | |
| # 1. Download BOTH schema and data files (~10 GB total, takes 5-10 min) | |
| python scripts/bulk_legislative_download.py --postgres --month 2026-04 | |
| # This downloads TWO files: | |
| # - 2026-04-schema.pgdump (~50 MB) - creates tables | |
| # - 2026-04-public.pgdump (~10 GB) - contains data | |
| # 2. Create database | |
| createdb openstates | |
| # 3. Restore schema first (creates all tables, ~30 seconds) | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --clean \ | |
| --if-exists \ | |
| --no-owner \ | |
| --no-privileges \ | |
| data/cache/legislation_bulk/postgres/2026-04-schema.pgdump | |
| # 4. Restore data (takes 10-15 minutes for 9.8 GB) | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --data-only \ | |
| --disable-triggers \ | |
| --no-owner \ | |
| --no-privileges \ | |
| data/cache/legislation_bulk/postgres/2026-04-public.pgdump | |
| # 5. Verify tables loaded | |
| psql openstates -c "\dt" | grep opencivicdata | |
| # 6. Test query | |
| psql openstates -c "SELECT COUNT(*) FROM opencivicdata_person;" | |
| # 7. Add to .env file | |
| echo "OPENSTATES_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/openstates" >> .env | |
| # 8. Test Python connection | |
| python -c " | |
| from dotenv import load_dotenv | |
| import os | |
| from sqlalchemy import create_engine | |
| load_dotenv() | |
| engine = create_engine(os.getenv('OPENSTATES_DATABASE_URL')) | |
| with engine.connect() as conn: | |
| from sqlalchemy import text | |
| result = conn.execute(text('SELECT COUNT(*) FROM opencivicdata_person')) | |
| print(f'β Connected! Total people: {result.scalar()}') | |
| " | |
| ``` | |
| **Expected Output:** | |
| ``` | |
| β Connected! Total people: 7342 | |
| ``` | |
| You're ready to query legislative data! | |
| ## οΏ½π₯ Download the Data | |
| The PostgreSQL dump requires **TWO separate files** from OpenStates: | |
| 1. **Schema file** (~50 MB): Creates all database tables, indexes, and constraints | |
| 2. **Data file** (~10 GB): Contains all the actual legislative data | |
| ```bash | |
| # Download both files for the latest month | |
| python scripts/bulk_legislative_download.py --postgres --month 2026-04 | |
| # Files saved to: | |
| # data/cache/legislation_bulk/postgres/2026-04-schema.pgdump (schema) | |
| # data/cache/legislation_bulk/postgres/2026-04-public.pgdump (data) | |
| ``` | |
| **Why two files?** | |
| OpenStates separates schema and data for flexibility: | |
| - **Schema file**: Small, updates rarely, creates table structure | |
| - **Data file**: Large, updated monthly, contains all records | |
| This allows you to update data without recreating the schema each time. | |
| **What's Included:** | |
| - **7,300+ state legislators** with complete profiles | |
| - **100,000+ bills** with full text (2020+) | |
| - **Committee assignments** and memberships | |
| - **Roll call votes** with individual legislator positions | |
| - **Bill sponsorships** and co-sponsors | |
| - **Bill actions** (timeline of committee/floor activity) | |
| - **Multiple bill versions** (as introduced, amended, enrolled) | |
| - **Legislator contact information** (district and capitol offices) | |
| ## ποΈ Load Data into PostgreSQL | |
| ### Option 1: Local PostgreSQL (Recommended for Development) | |
| **Prerequisites:** | |
| - PostgreSQL 15+ installed ([Download here](https://www.postgresql.org/download/)) | |
| - Both schema and data files downloaded (see above section) | |
| **Setup Steps (Two-Step Restore):** | |
| ```bash | |
| # 1. Create database | |
| createdb openstates | |
| # 2. Restore SCHEMA first (creates tables, indexes, constraints) | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --clean \ | |
| --if-exists \ | |
| --no-owner \ | |
| --no-privileges \ | |
| data/cache/legislation_bulk/postgres/2026-04-schema.pgdump | |
| # This takes ~30 seconds and creates all tables | |
| # 3. Restore DATA (loads all records into tables) | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --data-only \ | |
| --disable-triggers \ | |
| --no-owner \ | |
| --no-privileges \ | |
| data/cache/legislation_bulk/postgres/2026-04-public.pgdump | |
| # This takes 10-15 minutes for 9.8 GB of data | |
| # 4. Verify restoration | |
| psql openstates -c "\dt" # List all tables (should see 30+ opencivicdata_* tables) | |
| # 5. Test query | |
| psql openstates -c "SELECT COUNT(*) FROM opencivicdata_person;" | |
| # Expected: ~7,300+ legislators | |
| ``` | |
| **Add to `.env`:** | |
| ```bash | |
| OPENSTATES_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/openstates | |
| ``` | |
| **Automated Script:** | |
| For convenience, use the provided setup script that does both steps: | |
| ```bash | |
| ./scripts/setup_openstates_db.sh | |
| ``` | |
| This script: | |
| - β Checks that both schema and data files exist | |
| - β Creates the database if needed | |
| - β Restores schema first, then data | |
| - β Verifies the restore was successful | |
| - β Shows table counts | |
| **Troubleshooting:** | |
| ```bash | |
| # If you need to set PostgreSQL password | |
| psql -U postgres -c "ALTER USER postgres PASSWORD 'postgres';" | |
| # If port 5432 is busy, use different port | |
| psql -p 5433 # Then update .env accordingly | |
| ``` | |
| ### Option 2: Docker PostgreSQL (Clean Isolation) | |
| **Use a different port (5433) to avoid conflicts with main database:** | |
| ```bash | |
| # Start PostgreSQL container | |
| docker run -d \ | |
| --name openstates-db \ | |
| -e POSTGRES_PASSWORD=openstates \ | |
| -e POSTGRES_DB=openstates \ | |
| -p 5433:5432 \ | |
| -v $(pwd)/data/cache/legislation_bulk/postgres:/dumps \ | |
| postgres:15 | |
| # Restore SCHEMA first | |
| docker exec -i openstates-db \ | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --username=postgres \ | |
| --clean \ | |
| --if-exists \ | |
| --no-owner \ | |
| --no-privileges \ | |
| /dumps/2026-04-schema.pgdump | |
| # Restore DATA second | |
| docker exec -i openstates-db \ | |
| pg_restore \ | |
| --dbname=openstates \ | |
| --username=postgres \ | |
| --data-only \ | |
| --disable-triggers \ | |
| --no-owner \ | |
| --no-privileges \ | |
| /dumps/2026-04-public.pgdump | |
| # Test connection | |
| docker exec -it openstates-db psql -U postgres -d openstates -c "SELECT COUNT(*) FROM opencivicdata_person;" | |
| ``` | |
| **Add to `.env`:** | |
| ```bash | |
| OPENSTATES_DATABASE_URL=postgresql://postgres:openstates@localhost:5433/openstates | |
| ``` | |
| **Stop/Start Container:** | |
| ```bash | |
| docker stop openstates-db # Stop container | |
| docker start openstates-db # Start container | |
| docker rm openstates-db # Remove container | |
| ``` | |
| ## π Database Schema | |
| ### Core Tables | |
| The Open States database follows the [Popolo Project](https://www.popoloproject.com/) standard for representing political data. | |
| #### `opencivicdata_person` - Legislators | |
| ```sql | |
| SELECT | |
| id, -- OCD-ID: ocd-person/{uuid} | |
| name, -- Full name | |
| given_name, -- First name | |
| family_name, -- Last name | |
| gender, -- Male/Female/Other | |
| email, -- Official email | |
| biography, -- Bio text | |
| birth_date, -- Date of birth | |
| image, -- Photo URL | |
| created_at, | |
| updated_at | |
| FROM opencivicdata_person | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_personmembership` - Legislator Roles | |
| ```sql | |
| SELECT | |
| person_id, -- Links to opencivicdata_person.id | |
| organization_id, -- Legislature ID | |
| post_id, -- District/position | |
| label, -- "Senator"/"Representative" | |
| role, -- "upper"/"lower" | |
| start_date, -- Term start | |
| end_date -- Term end | |
| FROM opencivicdata_personmembership | |
| WHERE end_date > CURRENT_DATE -- Active legislators | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_bill` - Legislation | |
| ```sql | |
| SELECT | |
| id, -- OCD-ID: ocd-bill/{uuid} | |
| identifier, -- HB 123, SB 456 | |
| title, -- Bill title | |
| classification, -- bill/resolution/concurrent_resolution | |
| subject, -- Array of topics | |
| from_organization_id, -- Legislature | |
| legislative_session_id, | |
| created_at, | |
| updated_at | |
| FROM opencivicdata_bill | |
| WHERE subject @> ARRAY['Health']::varchar[] -- Health-related bills | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_billabstract` - Bill Summaries | |
| ```sql | |
| SELECT | |
| bill_id, | |
| abstract, -- Summary text | |
| note, -- "Official Summary" | |
| date | |
| FROM opencivicdata_billabstract | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_billsponsorship` - Bill Sponsors | |
| ```sql | |
| SELECT | |
| bill_id, | |
| person_id, -- Links to opencivicdata_person.id | |
| classification, -- "primary"/"cosponsor" | |
| primary_sponsorship, -- Boolean | |
| entity_type -- "person"/"organization" | |
| FROM opencivicdata_billsponsorship | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_voteevent` - Roll Call Votes | |
| ```sql | |
| SELECT | |
| id, -- OCD-ID: ocd-vote/{uuid} | |
| bill_id, | |
| organization_id, -- Chamber | |
| motion_text, -- "Passage of HB 123" | |
| motion_classification, -- "passage"/"amendment" | |
| result, -- "pass"/"fail" | |
| start_date, -- Vote date | |
| created_at, | |
| updated_at | |
| FROM opencivicdata_voteevent | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_personvote` - Individual Legislator Votes | |
| ```sql | |
| SELECT | |
| vote_event_id, | |
| voter_id, -- Links to opencivicdata_person.id | |
| option, -- "yes"/"no"/"abstain"/"absent" | |
| voter_name, -- Name at time of vote | |
| note | |
| FROM opencivicdata_personvote | |
| LIMIT 5; | |
| ``` | |
| #### `opencivicdata_organization` - Committees | |
| ```sql | |
| SELECT | |
| id, -- OCD-ID: ocd-organization/{uuid} | |
| name, -- "Committee on Health and Human Services" | |
| classification, -- "committee"/"subcommittee"/"legislature" | |
| parent_id, -- Parent committee (for subcommittees) | |
| jurisdiction_id, | |
| created_at, | |
| updated_at | |
| FROM opencivicdata_organization | |
| WHERE classification = 'committee' | |
| LIMIT 5; | |
| ``` | |
| ## π Useful Queries | |
| ### Find All Health-Related Bills in Alabama (2024) | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| b.subject, | |
| p.name AS sponsor, | |
| b.created_at | |
| FROM opencivicdata_bill b | |
| LEFT JOIN opencivicdata_billsponsorship bs ON bs.bill_id = b.id AND bs.primary_sponsorship = true | |
| LEFT JOIN opencivicdata_person p ON p.id = bs.person_id | |
| LEFT JOIN opencivicdata_legislativesession ls ON ls.id = b.legislative_session_id | |
| WHERE ls.jurisdiction_id = 'ocd-jurisdiction/country:us/state:al/government' | |
| AND ls.identifier LIKE '2024%' | |
| AND ( | |
| b.subject @> ARRAY['Health']::varchar[] | |
| OR b.title ILIKE '%dental%' | |
| OR b.title ILIKE '%oral health%' | |
| OR b.title ILIKE '%medicaid%' | |
| ) | |
| ORDER BY b.created_at DESC; | |
| ``` | |
| ### List Active Legislators with Committee Assignments | |
| ```sql | |
| SELECT | |
| p.name, | |
| p.party_name, | |
| pm.role AS chamber, | |
| pm.label AS position, | |
| o.name AS committee | |
| FROM opencivicdata_person p | |
| JOIN opencivicdata_personmembership pm | |
| ON pm.person_id = p.id | |
| AND pm.end_date > CURRENT_DATE | |
| LEFT JOIN opencivicdata_personmembership cm | |
| ON cm.person_id = p.id | |
| AND cm.end_date > CURRENT_DATE | |
| LEFT JOIN opencivicdata_organization o | |
| ON o.id = cm.organization_id | |
| AND o.classification = 'committee' | |
| WHERE pm.organization_id LIKE 'ocd-organization%/legislature' | |
| ORDER BY p.name; | |
| ``` | |
| ### Track Bill Progress Through Legislature | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| ba.date AS action_date, | |
| ba.description AS action, | |
| ba.classification, | |
| o.name AS organization | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_billaction ba ON ba.bill_id = b.id | |
| LEFT JOIN opencivicdata_organization o ON o.id = ba.organization_id | |
| WHERE b.identifier = 'HB 123' | |
| AND b.from_organization_id LIKE '%state:al%' | |
| ORDER BY ba.date; | |
| ``` | |
| ### Count Bills by Legislator (Top Sponsors) | |
| ```sql | |
| SELECT | |
| p.name, | |
| COUNT(DISTINCT bs.bill_id) AS bills_sponsored | |
| FROM opencivicdata_person p | |
| JOIN opencivicdata_billsponsorship bs ON bs.person_id = p.id | |
| WHERE bs.primary_sponsorship = true | |
| GROUP BY p.id, p.name | |
| ORDER BY bills_sponsored DESC | |
| LIMIT 20; | |
| ``` | |
| ### Find Roll Call Votes on Health Bills | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| v.motion_text, | |
| v.result, | |
| v.start_date, | |
| COUNT(CASE WHEN pv.option = 'yes' THEN 1 END) AS yes_votes, | |
| COUNT(CASE WHEN pv.option = 'no' THEN 1 END) AS no_votes, | |
| COUNT(CASE WHEN pv.option = 'abstain' THEN 1 END) AS abstain_votes | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_voteevent v ON v.bill_id = b.id | |
| LEFT JOIN opencivicdata_personvote pv ON pv.vote_event_id = v.id | |
| WHERE b.subject @> ARRAY['Health']::varchar[] | |
| GROUP BY b.id, b.identifier, b.title, v.id, v.motion_text, v.result, v.start_date | |
| ORDER BY v.start_date DESC; | |
| ``` | |
| ## π Python Integration with SQLAlchemy | |
| ### Using Environment Variables | |
| **Setup `.env` file first (see above), then:** | |
| ```python | |
| import os | |
| from sqlalchemy import create_engine, text | |
| import pandas as pd | |
| from dotenv import load_dotenv | |
| # Load environment variables | |
| load_dotenv() | |
| # Connect using OPENSTATES_DATABASE_URL from .env | |
| engine = create_engine(os.getenv('OPENSTATES_DATABASE_URL')) | |
| # Test connection | |
| with engine.connect() as conn: | |
| result = conn.execute(text("SELECT COUNT(*) FROM opencivicdata_person")) | |
| print(f"Total people in database: {result.scalar()}") | |
| # Query legislators | |
| legislators_df = pd.read_sql_query(""" | |
| SELECT | |
| p.name, | |
| p.email, | |
| p.party_name, | |
| pm.role AS chamber, | |
| pm.label AS position, | |
| j.name AS state | |
| FROM opencivicdata_person p | |
| JOIN opencivicdata_personmembership pm ON pm.person_id = p.id | |
| JOIN opencivicdata_jurisdiction j ON j.id LIKE '%' || pm.organization_id || '%' | |
| WHERE pm.end_date > CURRENT_DATE | |
| LIMIT 100 | |
| """, engine) | |
| print(f"Active legislators: {len(legislators_df)}") | |
| print(legislators_df.head()) | |
| # Query health bills | |
| health_bills_df = pd.read_sql_query(""" | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| b.subject, | |
| ls.identifier AS session | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_legislativesession ls ON ls.id = b.legislative_session_id | |
| WHERE b.subject @> ARRAY['Health']::varchar[] | |
| AND ls.identifier LIKE '2024%' | |
| LIMIT 50 | |
| """, engine) | |
| print(f"Health bills in 2024: {len(health_bills_df)}") | |
| ``` | |
| ### Complete Example Script | |
| Save as `scripts/query_openstates.py`: | |
| ```python | |
| #!/usr/bin/env python3 | |
| """ | |
| Query Open States PostgreSQL database for legislative data. | |
| Usage: | |
| python scripts/query_openstates.py --state al --topic health | |
| """ | |
| import os | |
| import argparse | |
| from sqlalchemy import create_engine | |
| import pandas as pd | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| def get_engine(): | |
| """Create database engine from environment variable.""" | |
| db_url = os.getenv('OPENSTATES_DATABASE_URL') | |
| if not db_url: | |
| raise ValueError( | |
| "OPENSTATES_DATABASE_URL not set in .env file. " | |
| "See website/docs/guides/open-states-legislative-data.md for setup." | |
| ) | |
| return create_engine(db_url) | |
| def query_legislators(engine, state_code=None): | |
| """Get active legislators, optionally filtered by state.""" | |
| where_clause = "" | |
| if state_code: | |
| where_clause = f"AND j.id LIKE '%state:{state_code}%'" | |
| query = f""" | |
| SELECT | |
| p.name, | |
| p.email, | |
| p.party_name, | |
| pm.role AS chamber, | |
| pm.label AS position, | |
| j.name AS state | |
| FROM opencivicdata_person p | |
| JOIN opencivicdata_personmembership pm ON pm.person_id = p.id | |
| JOIN opencivicdata_jurisdiction j ON j.id LIKE '%' || pm.organization_id || '%' | |
| WHERE pm.end_date > CURRENT_DATE | |
| {where_clause} | |
| ORDER BY p.name | |
| """ | |
| return pd.read_sql_query(query, engine) | |
| def query_bills_by_topic(engine, topic, state_code=None, year=2024): | |
| """Get bills by topic (Health, Education, etc.).""" | |
| where_clause = "" | |
| if state_code: | |
| where_clause = f"AND ls.jurisdiction_id LIKE '%state:{state_code}%'" | |
| query = f""" | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| b.subject, | |
| ls.identifier AS session, | |
| ls.jurisdiction_id | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_legislativesession ls ON ls.id = b.legislative_session_id | |
| WHERE b.subject @> ARRAY['{topic}']::varchar[] | |
| AND ls.identifier LIKE '{year}%' | |
| {where_clause} | |
| ORDER BY b.created_at DESC | |
| """ | |
| return pd.read_sql_query(query, engine) | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Query Open States database") | |
| parser.add_argument("--state", help="State code (e.g., al, ca, ny)") | |
| parser.add_argument("--topic", default="Health", help="Bill topic") | |
| parser.add_argument("--year", type=int, default=2024, help="Legislative year") | |
| args = parser.parse_args() | |
| engine = get_engine() | |
| print(f"\nπ Querying Open States Database...") | |
| print(f" State: {args.state or 'All'}") | |
| print(f" Topic: {args.topic}") | |
| print(f" Year: {args.year}") | |
| # Get legislators | |
| legislators = query_legislators(engine, args.state) | |
| print(f"\nπ₯ Active Legislators: {len(legislators)}") | |
| print(legislators.head()) | |
| # Get bills | |
| bills = query_bills_by_topic(engine, args.topic, args.state, args.year) | |
| print(f"\nπ {args.topic} Bills in {args.year}: {len(bills)}") | |
| print(bills.head()) | |
| # Save to CSV | |
| output_dir = "output/openstates_queries" | |
| os.makedirs(output_dir, exist_ok=True) | |
| legislators.to_csv(f"{output_dir}/legislators_{args.state or 'all'}.csv", index=False) | |
| bills.to_csv(f"{output_dir}/bills_{args.topic}_{args.year}.csv", index=False) | |
| print(f"\nβ Results saved to {output_dir}/") | |
| ``` | |
| **Run it:** | |
| ```bash | |
| # Query all health bills in Alabama for 2024 | |
| python scripts/query_openstates.py --state al --topic Health --year 2024 | |
| # Query all education bills nationwide | |
| python scripts/query_openstates.py --topic Education | |
| ``` | |
| ## π Export to Parquet for HuggingFace | |
| ### Complete Export Script | |
| Save as `scripts/export_openstates_parquet.py`: | |
| ```python | |
| #!/usr/bin/env python3 | |
| """ | |
| Export Open States PostgreSQL data to Parquet files for HuggingFace. | |
| Usage: | |
| python scripts/export_openstates_parquet.py --output data/gold/legislation/ | |
| """ | |
| import os | |
| import argparse | |
| import pandas as pd | |
| from sqlalchemy import create_engine | |
| from dotenv import load_dotenv | |
| from loguru import logger | |
| load_dotenv() | |
| def get_engine(): | |
| """Get database engine from environment.""" | |
| db_url = os.getenv('OPENSTATES_DATABASE_URL') | |
| if not db_url: | |
| raise ValueError("OPENSTATES_DATABASE_URL not set in .env") | |
| return create_engine(db_url) | |
| def export_legislators(engine, output_dir): | |
| """Export active legislators.""" | |
| logger.info("Exporting legislators...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| p.id AS legislator_id, | |
| p.name AS full_name, | |
| p.given_name, | |
| p.family_name, | |
| p.gender, | |
| p.email, | |
| p.biography, | |
| p.birth_date, | |
| p.death_date, | |
| p.image, | |
| p.party[1]->>'name' AS party_name, | |
| p.created_at, | |
| p.updated_at | |
| FROM opencivicdata_person p | |
| WHERE id IN ( | |
| SELECT DISTINCT person_id | |
| FROM opencivicdata_personmembership | |
| WHERE end_date > CURRENT_DATE | |
| ) | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_legislators.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} legislators to {output_path}") | |
| return len(df) | |
| def export_legislator_roles(engine, output_dir): | |
| """Export legislator roles/terms.""" | |
| logger.info("Exporting legislator roles...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| pm.id AS role_id, | |
| pm.person_id AS legislator_id, | |
| pm.organization_id AS legislature_id, | |
| pm.post_id, | |
| pm.label AS position, | |
| pm.role AS chamber, | |
| pm.start_date AS term_start, | |
| pm.end_date AS term_end, | |
| pm.created_at, | |
| pm.updated_at | |
| FROM opencivicdata_personmembership pm | |
| WHERE pm.organization_id LIKE 'ocd-organization%' | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_legislator_roles.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} roles to {output_path}") | |
| return len(df) | |
| def export_bills(engine, output_dir): | |
| """Export bills from 2020+.""" | |
| logger.info("Exporting bills (2020+)...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| b.id AS bill_id, | |
| b.identifier, | |
| b.title, | |
| b.classification, | |
| b.subject, | |
| b.from_organization_id AS legislature_id, | |
| b.legislative_session_id, | |
| b.created_at, | |
| b.updated_at | |
| FROM opencivicdata_bill b | |
| WHERE b.created_at >= '2020-01-01' | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_bills.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} bills to {output_path}") | |
| return len(df) | |
| def export_bill_sponsors(engine, output_dir): | |
| """Export bill sponsorships.""" | |
| logger.info("Exporting bill sponsors...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| bs.id AS sponsor_id, | |
| bs.bill_id, | |
| bs.person_id AS legislator_id, | |
| bs.classification AS sponsor_type, | |
| bs.primary AS is_primary_sponsor, | |
| bs.entity_type | |
| FROM opencivicdata_billsponsorship bs | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_bill_sponsors.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} sponsorships to {output_path}") | |
| return len(df) | |
| def export_vote_events(engine, output_dir): | |
| """Export vote events from 2020+.""" | |
| logger.info("Exporting vote events...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| v.id AS vote_event_id, | |
| v.bill_id, | |
| v.organization_id, | |
| v.motion_text, | |
| v.motion_classification, | |
| v.result, | |
| v.start_date AS vote_date, | |
| v.created_at, | |
| v.updated_at | |
| FROM opencivicdata_voteevent v | |
| WHERE v.start_date >= '2020-01-01' | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_vote_events.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} vote events to {output_path}") | |
| return len(df) | |
| def export_legislator_votes(engine, output_dir): | |
| """Export individual legislator votes.""" | |
| logger.info("Exporting legislator votes...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| pv.id AS legislator_vote_id, | |
| pv.vote_event_id, | |
| pv.voter_id AS legislator_id, | |
| pv.option AS vote_position, | |
| pv.voter_name, | |
| pv.note | |
| FROM opencivicdata_personvote pv | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_legislator_votes.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} legislator votes to {output_path}") | |
| return len(df) | |
| def export_committees(engine, output_dir): | |
| """Export committees.""" | |
| logger.info("Exporting committees...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| o.id AS committee_id, | |
| o.jurisdiction_id, | |
| o.name, | |
| o.classification, | |
| o.parent_id, | |
| o.created_at, | |
| o.updated_at | |
| FROM opencivicdata_organization o | |
| WHERE o.classification IN ('committee', 'subcommittee') | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_committees.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} committees to {output_path}") | |
| return len(df) | |
| def export_committee_memberships(engine, output_dir): | |
| """Export committee memberships.""" | |
| logger.info("Exporting committee memberships...") | |
| df = pd.read_sql_query(""" | |
| SELECT | |
| pm.id AS membership_id, | |
| pm.organization_id AS committee_id, | |
| pm.person_id AS legislator_id, | |
| pm.role, | |
| pm.start_date, | |
| pm.end_date, | |
| pm.created_at, | |
| pm.updated_at | |
| FROM opencivicdata_personmembership pm | |
| WHERE pm.organization_id IN ( | |
| SELECT id FROM opencivicdata_organization | |
| WHERE classification IN ('committee', 'subcommittee') | |
| ) | |
| """, engine) | |
| output_path = os.path.join(output_dir, 'legislation_committee_memberships.parquet') | |
| df.to_parquet(output_path, index=False, compression='snappy') | |
| logger.success(f"β Exported {len(df)} memberships to {output_path}") | |
| return len(df) | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Export Open States data to Parquet") | |
| parser.add_argument( | |
| "--output", | |
| default="data/gold/legislation/", | |
| help="Output directory for Parquet files" | |
| ) | |
| args = parser.parse_args() | |
| os.makedirs(args.output, exist_ok=True) | |
| logger.info(f"π Exporting Open States data to {args.output}") | |
| engine = get_engine() | |
| # Export all tables | |
| stats = { | |
| 'legislators': export_legislators(engine, args.output), | |
| 'legislator_roles': export_legislator_roles(engine, args.output), | |
| 'bills': export_bills(engine, args.output), | |
| 'bill_sponsors': export_bill_sponsors(engine, args.output), | |
| 'vote_events': export_vote_events(engine, args.output), | |
| 'legislator_votes': export_legislator_votes(engine, args.output), | |
| 'committees': export_committees(engine, args.output), | |
| 'committee_memberships': export_committee_memberships(engine, args.output), | |
| } | |
| logger.success("\nβ Export complete!") | |
| logger.info("\nπ Summary:") | |
| for table, count in stats.items(): | |
| logger.info(f" {table}: {count:,} records") | |
| ``` | |
| **Run the export:** | |
| ```bash | |
| # Export all tables to Parquet | |
| python scripts/export_openstates_parquet.py --output data/gold/legislation/ | |
| # Verify files were created | |
| ls -lh data/gold/legislation/ | |
| # Output: | |
| # legislation_legislators.parquet | |
| # legislation_legislator_roles.parquet | |
| # legislation_bills.parquet | |
| # legislation_bill_sponsors.parquet | |
| # legislation_vote_events.parquet | |
| # legislation_legislator_votes.parquet | |
| # legislation_committees.parquet | |
| # legislation_committee_memberships.parquet | |
| ``` | |
| ### Upload to HuggingFace | |
| ```bash | |
| # Install huggingface-hub if not already installed | |
| pip install huggingface-hub | |
| # Upload datasets | |
| python scripts/upload_to_huggingface.py \ | |
| --dataset CommunityOne/open-navigator-data \ | |
| --folder data/gold/legislation/ | |
| ``` | |
| ## π Related Resources | |
| - **Open States Documentation:** https://docs.openstates.org/ | |
| - **Popolo Project Schema:** https://www.popoloproject.com/ | |
| - **Open Civic Data IDs:** https://opencivicdata.readthedocs.io/ | |
| - **Plural Policy Data Portal:** https://open.pluralpolicy.com/data/ | |
| - **PostgreSQL Monthly Dumps:** https://data.openstates.org/postgres/monthly/ | |
| ## π― Oral Health Policy Use Cases | |
| ### Finding Water Fluoridation Legislation | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| ls.jurisdiction_id, | |
| b.subject, | |
| b.created_at | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_legislativesession ls ON ls.id = b.legislative_session_id | |
| WHERE ( | |
| b.title ILIKE '%fluorid%' | |
| OR b.title ILIKE '%water treatment%' | |
| OR EXISTS ( | |
| SELECT 1 FROM opencivicdata_billabstract ba | |
| WHERE ba.bill_id = b.id | |
| AND ba.abstract ILIKE '%fluorid%' | |
| ) | |
| ) | |
| ORDER BY b.created_at DESC; | |
| ``` | |
| ### Tracking Medicaid Dental Coverage Expansion | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| p.name AS sponsor, | |
| v.result AS vote_outcome, | |
| v.start_date AS vote_date | |
| FROM opencivicdata_bill b | |
| LEFT JOIN opencivicdata_billsponsorship bs ON bs.bill_id = b.id AND bs.primary_sponsorship = true | |
| LEFT JOIN opencivicdata_person p ON p.id = bs.person_id | |
| LEFT JOIN opencivicdata_voteevent v ON v.bill_id = b.id | |
| WHERE ( | |
| b.title ILIKE '%medicaid%' AND b.title ILIKE '%dental%' | |
| OR b.title ILIKE '%medicaid%' AND b.title ILIKE '%oral health%' | |
| ) | |
| ORDER BY b.created_at DESC; | |
| ``` | |
| ### School-Based Dental Screening Programs | |
| ```sql | |
| SELECT | |
| b.identifier, | |
| b.title, | |
| ls.identifier AS session, | |
| j.name AS state | |
| FROM opencivicdata_bill b | |
| JOIN opencivicdata_legislativesession ls ON ls.id = b.legislative_session_id | |
| JOIN opencivicdata_jurisdiction j ON j.id = ls.jurisdiction_id | |
| WHERE ( | |
| b.title ILIKE '%school%' AND b.title ILIKE '%dental%' | |
| OR b.title ILIKE '%school%' AND b.title ILIKE '%oral health%' | |
| OR b.title ILIKE '%school nurse%' AND b.title ILIKE '%screening%' | |
| ) | |
| ORDER BY b.created_at DESC; | |
| ``` | |