Learning by Doing:
Python + SQL Server + California Crashes
Learning by Doing:
Python + SQL Server + California Crashes

Caso completo de laboratorio: convertir archivos CSV abiertos del California Crash Reporting System (CCRS) en una base SQL Server local, y luego transformar esa carga en análisis, KPIs, reglas de negocio e insights reales.Complete lab case study: converting open CSV files from the California Crash Reporting System (CCRS) into a local SQL Server database, then transforming that load into analysis, KPIs, business rules, and real insights.

0. Cybersecurity & Production Warning

Advertencia importante: este laboratorio debe ejecutarse solo en ambiente local, de práctica o desarrollo. No correr scripts, ETLs, cargas masivas ni automatizaciones en producción sin autorización, backups, pruebas previas, control de cambios, mínimos privilegios, revisión de seguridad y cumplimiento de los protocolos de cybersecurity de la organización.

1. La idea del ejercicio

El punto de partida fue simple: aprender Python con un caso real, no con ejercicios artificiales. Primero pensamos en PDFs, pero luego apareció una oportunidad mejor: trabajar con datos abiertos oficiales de accidentes de tránsito de California.

Open DataCSV grandesPython ETLSQL ServerSQL AnalyticsBusiness RulesInsights

Fuente abierta: California Crash Reporting System (CCRS), publicado en el California Open Data Portal.

https://data.ca.gov/dataset/ccrs

2. Archivos usados

Se descargaron los archivos Crashes del CCRS desde 2019 hasta 2026 y se guardaron en un folder local.

crashes_2019.csv
crashes_2020.csv
crashes_2021.csv
crashes_2022.csv
crashes_2023.csv
crashes_2024.csv
crashes_2025.csv
crashes_2026.csv
8
CSV files
3,043,697
Total records discovered
74
Columns per file
100%
Schema consistency

3. Proceso interactivo limpio, paso a paso

Validar Python

Primero se confirmó que Python estaba instalado y disponible desde PowerShell.

python --version

Instalar librerías necesarias

Se instalaron las librerías base para leer CSV, conectar SQL Server y cargar datos.

pip install pandas pyodbc sqlalchemy

Verificar ODBC Driver

Python necesitaba ver el driver correcto para hablar con SQL Server.

import pyodbc

print("Available SQL Server ODBC drivers:")
print(pyodbc.drivers())

Resultado: ODBC Driver 17 for SQL Server.

Probar conexión Python → SQL Server

Se usó Windows Authentication, sin pasar password en el script.

import pyodbc

try:
    conn = pyodbc.connect(
        "DRIVER={ODBC Driver 17 for SQL Server};"
        "SERVER=JCDCOMPUTER;"
        "DATABASE=master;"
        "Trusted_Connection=yes;"
    )

    print("SUCCESS - Connected to SQL Server")

    cursor = conn.cursor()
    cursor.execute("SELECT @@SERVERNAME")
    row = cursor.fetchone()

    print(f"Server: {row[0]}")
    conn.close()

except Exception as e:
    print("ERROR:")
    print(e)

Resultado: conexión exitosa a SQL Server.

Crear base de datos

En SQL Server Management Studio se creó la base local.

CREATE DATABASE CaliforniaCrashes;
GO

4. Profiling inicial de los CSV

Antes de cargar datos a SQL, se hizo un inventario. El objetivo era entender qué había realmente en el folder: archivos, tamaño, filas, columnas y nombres de campos.

from pathlib import Path
import pandas as pd

folder = Path.cwd()
csv_files = sorted(folder.glob("*.csv"))

print(f"Folder: {folder}")
print(f"CSV files found: {len(csv_files)}")
print("-" * 80)

inventory = []

for file in csv_files:
    print(f"Processing: {file.name}")

    sample = pd.read_csv(file, nrows=1000, low_memory=False)

    with open(file, "r", encoding="utf-8", errors="ignore") as f:
        row_count = sum(1 for _ in f) - 1

    inventory.append({
        "file_name": file.name,
        "size_mb": round(file.stat().st_size / (1024 * 1024), 2),
        "rows": row_count,
        "columns": len(sample.columns),
        "column_names": " | ".join(sample.columns)
    })

report = pd.DataFrame(inventory)
print(report[["file_name", "size_mb", "rows", "columns"]])
report.to_excel("csv_inventory_report.xlsx", index=False)
print("DONE - Report created: csv_inventory_report.xlsx")
FileRowsColumns
crashes_2019.csv478,18974
crashes_2020.csv374,75774
crashes_2021.csv422,98074
crashes_2022.csv405,24974
crashes_2023.csv409,81574
crashes_2024.csv416,91774
crashes_2025.csv398,26674
crashes_2026.csv137,52474

Insight técnico: los 8 archivos tienen 74 columnas. Eso abrió la posibilidad de una tabla única en SQL Server.

5. Validación del esquema

No bastaba con saber que todos tenían 74 columnas. Había que confirmar que los nombres y el orden fueran exactamente iguales.

from pathlib import Path
import pandas as pd

folder = Path.cwd()
csv_files = sorted(folder.glob("*.csv"))

reference_file = csv_files[0]
reference_columns = list(pd.read_csv(reference_file, nrows=0).columns)

print(f"Reference file: {reference_file.name}")
print(f"Reference columns: {len(reference_columns)}")
print("-" * 80)

all_ok = True

for file in csv_files:
    columns = list(pd.read_csv(file, nrows=0).columns)
    same_names = columns == reference_columns
    print(f"{file.name}: {'OK' if same_names else 'DIFFERENT'}")

    if not same_names:
        all_ok = False
        missing = set(reference_columns) - set(columns)
        extra = set(columns) - set(reference_columns)
        print(f"  Missing columns: {missing}")
        print(f"  Extra columns: {extra}")

print("-" * 80)

if all_ok:
    print("SUCCESS - All CSV files have the same columns in the same order.")
else:
    print("WARNING - Some CSV files have different columns.")

Resultado: todos los archivos tenían las mismas columnas en el mismo orden.

6. Limpieza de nombres de columnas

Se detectó que algunas columnas tenían espacios al inicio o nombres poco cómodos para SQL. Por eso se normalizaron a nombres SQL-friendly.

import pandas as pd
import re

df = pd.read_csv("crashes_2019.csv", nrows=0)

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    col = col.strip("_")
    return col

print("Original Column -> Clean Column")
print("-" * 80)

for col in df.columns:
    clean_col = clean_column_name(col)
    print(f"{col}  ->  {clean_col}")
OriginalClean
Crash Date TimeCrash_Date_Time
City NameCity_Name
Collision Type DescriptionCollision_Type_Description
Primary Collision Factor CodePrimary_Collision_Factor_Code
Weather 1Weather_1

7. Perfil de tipos y nulos

Se tomó una muestra de 10,000 filas para ver tipos de datos y valores nulos.

import pandas as pd

df = pd.read_csv(
    "crashes_2026.csv",
    nrows=10000,
    low_memory=False
)

profile = pd.DataFrame({
    "Column": df.columns,
    "DataType": df.dtypes.astype(str),
    "Nulls": df.isnull().sum().values,
    "NullPercent": round((df.isnull().sum() / len(df)) * 100, 2).values
})

print(profile)
profile.to_excel("column_profile.xlsx", index=False)
print("DONE - column_profile.xlsx created")

Observación: se detectaron columnas operacionales con alto porcentaje de nulos, algo normal en datasets administrativos: campos opcionales, evidencia, media, narrativas o datos que aplican solo a ciertos casos.

8. Validación de la llave: Collision Id

Antes de usar Collision Id como llave candidata, se verificó si era único en 2026.

import pandas as pd

df = pd.read_csv(
    "crashes_2026.csv",
    usecols=["Collision Id"]
)

print("Rows:", len(df))
print("Unique:", df["Collision Id"].nunique())
print("Duplicates:", len(df) - df["Collision Id"].nunique())
137,524
Rows
137,524
Unique Collision Id
0
Duplicates

Conclusión: Collision_Id es excelente candidato para llave primaria, al menos en el archivo 2026 validado.

9. Carga piloto: 10,000 filas a SQL Server

Primero se cargó una muestra controlada a dbo.Crashes_Test. Esto permitió validar conexión, tabla, nombres de columnas y velocidad sin arriesgar la carga completa.

import pandas as pd
import re
from sqlalchemy import create_engine

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    col = col.strip("_")
    return col

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(connection_string, fast_executemany=True)

print("Reading sample data...")

df = pd.read_csv(
    "crashes_2026.csv",
    nrows=10000,
    low_memory=False
)

df.columns = [clean_column_name(col) for col in df.columns]

print("Rows to load:", len(df))
print("Columns:", len(df.columns))
print("Loading data to SQL Server...")

df.to_sql(
    name="Crashes_Test",
    con=engine,
    schema="dbo",
    if_exists="replace",
    index=False,
    chunksize=1000
)

print("DONE - 10,000 rows loaded into dbo.Crashes_Test")
USE CaliforniaCrashes;
GO

SELECT COUNT(*) AS TotalRows
FROM dbo.Crashes_Test;
GO

SELECT TOP 10 *
FROM dbo.Crashes_Test;
GO

10. Carga completa de 2026 por chunks

Después de validar la muestra, se cargó completo el archivo 2026 usando chunks. Este fue el primer ETL completo del laboratorio.

import pandas as pd
import re
from sqlalchemy import create_engine

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    return col.strip("_")

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(
    connection_string,
    fast_executemany=True
)

csv_file = "crashes_2026.csv"
chunk_size = 25000

total_rows = 0
first_chunk = True

print("Starting load...")

for chunk in pd.read_csv(
    csv_file,
    chunksize=chunk_size,
    low_memory=False
):

    chunk.columns = [clean_column_name(c) for c in chunk.columns]

    chunk.to_sql(
        "Crashes_2026",
        con=engine,
        schema="dbo",
        if_exists="replace" if first_chunk else "append",
        index=False,
        chunksize=5000
    )

    total_rows += len(chunk)
    print(f"Loaded: {total_rows:,} rows")
    first_chunk = False

print()
print("DONE")
print(f"Total rows loaded: {total_rows:,}")
137,524
Rows loaded into dbo.Crashes_2026
SELECT COUNT(*) AS TotalRows
FROM dbo.Crashes_2026;

11. Primeros KPIs en SQL

Una vez cargada la tabla, el objetivo dejó de ser mover datos. El objetivo pasó a ser entender la historia que los datos contaban.

SELECT
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    SUM(NumberInjured) AS TotalInjured
FROM dbo.Crashes_2026;
137,524
Total Crashes
450
Total Killed
79,106
Total Injured

Valoración: ya no era una prueba técnica. La data empezó a producir indicadores de negocio.

12. Top ciudades por volumen

SELECT TOP 10
    City_Name,
    COUNT(*) AS TotalCrashes
FROM dbo.Crashes_2026
GROUP BY City_Name
ORDER BY COUNT(*) DESC;
CityTotal Crashes
Unincorporated41,652
Los Angeles9,833
San Diego3,162
Sacramento2,357
San Jose2,051
San Francisco2,007
Santa Ana1,902
Anaheim1,901
Riverside1,467
Stockton1,309

Insight: Unincorporated concentra una proporción enorme de accidentes. Eso no representa una ciudad única, sino áreas no incorporadas, probablemente con carreteras, zonas rurales o jurisdicciones fuera de límites urbanos.

13. Fatalidades por ciudad

SELECT TOP 10
    City_Name,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
GROUP BY City_Name
ORDER BY SUM(NumberKilled) DESC;

Insight: el ranking por volumen y el ranking por fatalidades no cuentan la misma historia. Una ciudad puede tener muchos accidentes, pero otra puede tener menos accidentes y mayor severidad.

Ese fue el primer salto analítico importante: pasar de contar eventos a medir severidad.

14. Tasa de fatalidad por ciudad

Para eliminar el sesgo del volumen, se calculó la tasa de fatalidad. Se usó un umbral mínimo para evitar distorsiones por muestras demasiado pequeñas.

SELECT TOP 20
    City_Name,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        NULLIF(COUNT(*),0)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
GROUP BY City_Name
HAVING COUNT(*) > 100
ORDER BY FatalityRatePct DESC;
CityCrashesKilledFatality %
Madera23462.56%
Signal Hill12332.44%
Chico14132.13%
Palm Springs16531.82%
Victorville67471.04%
Pomona82780.97%
Los Angeles9,833900.92%

Valoración: Los Angeles domina en volumen, pero algunas ciudades más pequeñas presentan tasas relativas más altas. Esto enseña una regla de negocio clave: volumen y riesgo no son lo mismo.

15. Tipo de colisión: volumen, fatalidad y severidad

SELECT TOP 20
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    SUM(NumberInjured) AS TotalInjured
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Description
ORDER BY SUM(NumberKilled) DESC;
Collision TypeCrashesKilledInjured
VEHICLE/PEDESTRAIN3,8921403,704
HIT OBJECT24,5601079,064
HEAD-ON5,537625,224
BROADSIDE23,2736021,845
OVERTURNED2,848282,403
REAR END41,7332725,745
OTHER4,145151,849
SIDE SWIPE13,129119,163

Hallazgo: REAR END es el tipo más frecuente, pero VEHICLE/PEDESTRAIN concentra más muertes con menos volumen. Esa diferencia es oro analítico.

Fatality Severity Index

SELECT
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        COUNT(*)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Description
ORDER BY FatalityRatePct DESC;
Collision TypeFatality Rate
VEHICLE/PEDESTRAIN3.60%
HEAD-ON1.12%
OVERTURNED0.98%
HIT OBJECT0.44%
OTHER0.36%
BROADSIDE0.26%
REAR END0.06%
SIDE SWIPE0.04%

Insight ejecutivo: un choque VEHICLE/PEDESTRAIN fue aproximadamente 60 veces más letal que un REAR END en este corte de 2026.

16. Problema real de calidad: PEDESTRAIN

Durante el análisis apareció una lección de data quality: el valor del campo no decía PEDESTRIAN, sino PEDESTRAIN. Por eso el filtro por texto no devolvía resultados. La solución profesional fue filtrar por código.

SELECT DISTINCT
    Collision_Type_Code,
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Code, Collision_Type_Description
ORDER BY SUM(NumberKilled) DESC;

Regla de negocio: usar Collision_Type_Code = 'G' para representar VEHICLE/PEDESTRAIN y evitar depender del texto mal escrito.

17. Peatones por ciudad

SELECT TOP 20
    City_Name,
    COUNT(*) AS PedestrianCrashes,
    SUM(NumberKilled) AS PedestrianKilled
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY City_Name
ORDER BY SUM(NumberKilled) DESC;
CityPedestrian CrashesKilled
Los Angeles43240
Unincorporated52122
Victorville344
San Francisco1614
Lancaster244

Insight: Los Angeles tuvo menos accidentes peatón que Unincorporated, pero casi el doble de fallecidos. Eso sugiere que la severidad de los accidentes peatón no está determinada solo por el volumen.

18. Tasa de fatalidad en accidentes peatón

SELECT TOP 20
    City_Name,
    COUNT(*) AS PedestrianCrashes,
    SUM(NumberKilled) AS PedestrianKilled,
    CAST(SUM(NumberKilled) * 100.0 / COUNT(*) AS DECIMAL(10,2)) AS PedestrianFatalityRate
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY City_Name
HAVING COUNT(*) >= 10
ORDER BY PedestrianFatalityRate DESC;
CityPedestrian CrashesKilledFatality %
Madera10220.00%
Lancaster24416.67%
Eureka17211.76%
Victorville34411.76%
Richmond18211.11%
Los Angeles432409.26%

Nota analítica: las ciudades con pocos casos pueden mostrar tasas muy altas. Hay que usar umbrales como HAVING COUNT(*) >= 50 o >= 100 para análisis más estables.

19. Iluminación en accidentes peatón

La pregunta evolucionó: si los accidentes con peatones son los más letales, ¿qué papel juega la iluminación?

SELECT
    LightingDescription,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY LightingDescription
ORDER BY SUM(NumberKilled) DESC;
LightingCrashesKilled
DARK-STREET LIGHTS1,27069
DAYLIGHT2,20833
DARK-NO STREET LIGHTS20627
DARK-STREET LIGHTS NOT FUNCTIONING217
DUSK-DAWN1624

Tasa de fatalidad por iluminación

SELECT
    LightingDescription,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        COUNT(*)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY LightingDescription
ORDER BY FatalityRatePct DESC;
Lighting ConditionFatality Rate
DARK-STREET LIGHTS NOT FUNCTIONING33.33%
DARK-NO STREET LIGHTS13.11%
DARK-STREET LIGHTS5.43%
DUSK-DAWN2.47%
DAYLIGHT1.49%

Hallazgo principal: los accidentes peatón en oscuridad sin iluminación tienen una tasa de fatalidad mucho mayor que los accidentes en daylight. En este corte, DARK-NO STREET LIGHTS fue casi 9 veces más letal que DAYLIGHT.

20. Valoración ejecutiva del viaje analítico

El valor real de la sesión no fue instalar Python ni cargar un CSV. El valor fue recorrer un ciclo completo de pensamiento analítico:

PreguntaDatosValidaciónETLKPIsNormalizaciónInsightsHipótesis

Hallazgos generados

  • Rear End es muy frecuente, pero de baja mortalidad relativa.
  • Vehicle/Pedestrian es mucho menos frecuente, pero es el tipo más letal.
  • Head-On y Overturned tienen alta severidad relativa.
  • El ranking por volumen no es igual al ranking por riesgo.
  • Los Angeles concentra muchas fatalidades peatón.
  • La oscuridad, especialmente sin iluminación, aparece como factor crítico de severidad en accidentes peatón.
  • El error textual PEDESTRAIN demostró la importancia de usar códigos cuando los textos no son confiables.

La regla maestra

No basta con contar accidentes. Hay que medir severidad, normalizar por volumen, separar frecuencia de riesgo y seguir preguntando hasta que el dato empiece a explicar el problema.

21. Lo que queda para la siguiente sesión

3M+
Cargar todos los años a dbo.Crashes
2019-2026
Tendencias históricas
Power BI
Dashboard ejecutivo
ML
Forecasting / Prediction

Próximo script propuesto: load_all_years.py

El próximo paso natural es cargar los 8 archivos en una sola tabla final dbo.Crashes, agregando una columna derivada Source_Year o usando la propia fecha del accidente para análisis temporal.

22. Conclusión

Este laboratorio demostró que Python puede funcionar como un ETL local real: lee archivos grandes, procesa por chunks, normaliza columnas, carga SQL Server y habilita análisis inmediato.

Pero la lección más importante fue otra: Python no fue el objetivo; Python fue el vehículo.

El verdadero objetivo fue transformar datos abiertos en conocimiento usando una combinación de experiencia de negocio, reglas analíticas, SQL, pensamiento crítico e IA como partner.

Learning by DoingPythonSQL ServerOpen DataETLBusiness IntelligenceTraffic Safety Analytics

0. Cybersecurity & Production Warning

Important warning: this lab should only be executed in a local, practice, or development environment. Do not run scripts, ETLs, bulk loads, or automations in production without authorization, backups, prior testing, change control, least-privilege access, security review, and compliance with the organization’s cybersecurity protocols.

1. The idea of the exercise

The starting point was simple: learn Python using a real case, not artificial exercises. At first, the idea was to work with PDFs, but then a stronger opportunity appeared: using official open traffic crash data from California.

Open DataLarge CSV filesPython ETLSQL ServerSQL AnalyticsBusiness RulesInsights

Open source: California Crash Reporting System (CCRS), published through the California Open Data Portal.

https://data.ca.gov/dataset/ccrs

2. Files used

The Crashes files from CCRS were downloaded from 2019 through 2026 and saved in a local folder.

crashes_2019.csv
crashes_2020.csv
crashes_2021.csv
crashes_2022.csv
crashes_2023.csv
crashes_2024.csv
crashes_2025.csv
crashes_2026.csv
8
CSV files
3,043,697
Total records discovered
74
Columns per file
100%
Schema consistency

3. Clean interactive process, step by step

Validate Python

The first step was confirming that Python was installed and available from PowerShell.

python --version

Install required libraries

The base libraries were installed to read CSV files, connect to SQL Server, and load data.

pip install pandas pyodbc sqlalchemy

Verify the ODBC Driver

Python needed to see the correct driver in order to communicate with SQL Server.

import pyodbc

print("Available SQL Server ODBC drivers:")
print(pyodbc.drivers())

Result: ODBC Driver 17 for SQL Server.

Test Python → SQL Server connection

Windows Authentication was used, without passing a password in the script.

import pyodbc

try:
    conn = pyodbc.connect(
        "DRIVER={ODBC Driver 17 for SQL Server};"
        "SERVER=JCDCOMPUTER;"
        "DATABASE=master;"
        "Trusted_Connection=yes;"
    )

    print("SUCCESS - Connected to SQL Server")

    cursor = conn.cursor()
    cursor.execute("SELECT @@SERVERNAME")
    row = cursor.fetchone()

    print(f"Server: {row[0]}")
    conn.close()

except Exception as e:
    print("ERROR:")
    print(e)

Result: successful connection to SQL Server.

Create the database

The local database was created in SQL Server Management Studio.

CREATE DATABASE CaliforniaCrashes;
GO

4. Initial CSV profiling

Before loading data into SQL, an inventory was created. The goal was to understand what was actually inside the folder: files, size, rows, columns, and field names.

from pathlib import Path
import pandas as pd

folder = Path.cwd()
csv_files = sorted(folder.glob("*.csv"))

print(f"Folder: {folder}")
print(f"CSV files found: {len(csv_files)}")
print("-" * 80)

inventory = []

for file in csv_files:
    print(f"Processing: {file.name}")

    sample = pd.read_csv(file, nrows=1000, low_memory=False)

    with open(file, "r", encoding="utf-8", errors="ignore") as f:
        row_count = sum(1 for _ in f) - 1

    inventory.append({
        "file_name": file.name,
        "size_mb": round(file.stat().st_size / (1024 * 1024), 2),
        "rows": row_count,
        "columns": len(sample.columns),
        "column_names": " | ".join(sample.columns)
    })

report = pd.DataFrame(inventory)
print(report[["file_name", "size_mb", "rows", "columns"]])
report.to_excel("csv_inventory_report.xlsx", index=False)
print("DONE - Report created: csv_inventory_report.xlsx")
FileRowsColumns
crashes_2019.csv478,18974
crashes_2020.csv374,75774
crashes_2021.csv422,98074
crashes_2022.csv405,24974
crashes_2023.csv409,81574
crashes_2024.csv416,91774
crashes_2025.csv398,26674
crashes_2026.csv137,52474

Technical insight: the 8 files all had 74 columns. That opened the possibility of using a single SQL Server table.

5. Schema validation

It was not enough to know that every file had 74 columns. The names and column order also needed to match exactly.

from pathlib import Path
import pandas as pd

folder = Path.cwd()
csv_files = sorted(folder.glob("*.csv"))

reference_file = csv_files[0]
reference_columns = list(pd.read_csv(reference_file, nrows=0).columns)

print(f"Reference file: {reference_file.name}")
print(f"Reference columns: {len(reference_columns)}")
print("-" * 80)

all_ok = True

for file in csv_files:
    columns = list(pd.read_csv(file, nrows=0).columns)
    same_names = columns == reference_columns
    print(f"{file.name}: {'OK' if same_names else 'DIFFERENT'}")

    if not same_names:
        all_ok = False
        missing = set(reference_columns) - set(columns)
        extra = set(columns) - set(reference_columns)
        print(f"  Missing columns: {missing}")
        print(f"  Extra columns: {extra}")

print("-" * 80)

if all_ok:
    print("SUCCESS - All CSV files have the same columns in the same order.")
else:
    print("WARNING - Some CSV files have different columns.")

Result: all files had the same columns in the same order.

6. Cleaning column names

Some columns had leading spaces or names that were not convenient for SQL. They were normalized into SQL-friendly names.

import pandas as pd
import re

df = pd.read_csv("crashes_2019.csv", nrows=0)

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    col = col.strip("_")
    return col

print("Original Column -> Clean Column")
print("-" * 80)

for col in df.columns:
    clean_col = clean_column_name(col)
    print(f"{col}  ->  {clean_col}")
OriginalClean
Crash Date TimeCrash_Date_Time
City NameCity_Name
Collision Type DescriptionCollision_Type_Description
Primary Collision Factor CodePrimary_Collision_Factor_Code
Weather 1Weather_1

7. Data types and null profile

A 10,000-row sample was used to inspect data types and null values.

import pandas as pd

df = pd.read_csv(
    "crashes_2026.csv",
    nrows=10000,
    low_memory=False
)

profile = pd.DataFrame({
    "Column": df.columns,
    "DataType": df.dtypes.astype(str),
    "Nulls": df.isnull().sum().values,
    "NullPercent": round((df.isnull().sum() / len(df)) * 100, 2).values
})

print(profile)
profile.to_excel("column_profile.xlsx", index=False)
print("DONE - column_profile.xlsx created")

Observation: some operational fields showed high null percentages, which is normal in administrative datasets: optional fields, evidence, media files, narratives, or data that only applies to certain cases.

8. Key validation: Collision Id

Before using Collision Id as a candidate key, uniqueness was tested in the 2026 file.

import pandas as pd

df = pd.read_csv(
    "crashes_2026.csv",
    usecols=["Collision Id"]
)

print("Rows:", len(df))
print("Unique:", df["Collision Id"].nunique())
print("Duplicates:", len(df) - df["Collision Id"].nunique())
137,524
Rows
137,524
Unique Collision Id
0
Duplicates

Conclusion: Collision_Id is an excellent primary-key candidate, at least in the validated 2026 file.

9. Pilot load: 10,000 rows to SQL Server

A controlled sample was first loaded to dbo.Crashes_Test. This validated the connection, table creation, column names, and speed before attempting the full load.

import pandas as pd
import re
from sqlalchemy import create_engine

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    col = col.strip("_")
    return col

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(connection_string, fast_executemany=True)

print("Reading sample data...")

df = pd.read_csv(
    "crashes_2026.csv",
    nrows=10000,
    low_memory=False
)

df.columns = [clean_column_name(col) for col in df.columns]

print("Rows to load:", len(df))
print("Columns:", len(df.columns))
print("Loading data to SQL Server...")

df.to_sql(
    name="Crashes_Test",
    con=engine,
    schema="dbo",
    if_exists="replace",
    index=False,
    chunksize=1000
)

print("DONE - 10,000 rows loaded into dbo.Crashes_Test")
USE CaliforniaCrashes;
GO

SELECT COUNT(*) AS TotalRows
FROM dbo.Crashes_Test;
GO

SELECT TOP 10 *
FROM dbo.Crashes_Test;
GO

10. Full 2026 load by chunks

After validating the sample, the complete 2026 file was loaded using chunks. This became the first complete ETL of the lab.

import pandas as pd
import re
from sqlalchemy import create_engine

def clean_column_name(col):
    col = col.strip()
    col = re.sub(r"[^A-Za-z0-9]+", "_", col)
    return col.strip("_")

server = "JCDCOMPUTER"
database = "CaliforniaCrashes"
driver = "ODBC Driver 17 for SQL Server"

connection_string = (
    f"mssql+pyodbc://@{server}/{database}"
    f"?driver={driver.replace(' ', '+')}"
    f"&trusted_connection=yes"
)

engine = create_engine(
    connection_string,
    fast_executemany=True
)

csv_file = "crashes_2026.csv"
chunk_size = 25000

total_rows = 0
first_chunk = True

print("Starting load...")

for chunk in pd.read_csv(
    csv_file,
    chunksize=chunk_size,
    low_memory=False
):

    chunk.columns = [clean_column_name(c) for c in chunk.columns]

    chunk.to_sql(
        "Crashes_2026",
        con=engine,
        schema="dbo",
        if_exists="replace" if first_chunk else "append",
        index=False,
        chunksize=5000
    )

    total_rows += len(chunk)
    print(f"Loaded: {total_rows:,} rows")
    first_chunk = False

print()
print("DONE")
print(f"Total rows loaded: {total_rows:,}")
137,524
Rows loaded into dbo.Crashes_2026
SELECT COUNT(*) AS TotalRows
FROM dbo.Crashes_2026;

11. First SQL KPIs

Once the table was loaded, the goal stopped being data movement. The goal became understanding the story the data was telling.

SELECT
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    SUM(NumberInjured) AS TotalInjured
FROM dbo.Crashes_2026;
137,524
Total Crashes
450
Total Killed
79,106
Total Injured

Assessment: this was no longer a technical test. The data started producing business indicators.

12. Top cities by volume

SELECT TOP 10
    City_Name,
    COUNT(*) AS TotalCrashes
FROM dbo.Crashes_2026
GROUP BY City_Name
ORDER BY COUNT(*) DESC;
CityTotal Crashes
Unincorporated41,652
Los Angeles9,833
San Diego3,162
Sacramento2,357
San Jose2,051
San Francisco2,007
Santa Ana1,902
Anaheim1,901
Riverside1,467
Stockton1,309

Insight: Unincorporated concentrates a very large share of crashes. This is not one single city, but unincorporated areas, likely including roads, rural zones, or jurisdictions outside city boundaries.

13. Fatalities by city

SELECT TOP 10
    City_Name,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
GROUP BY City_Name
ORDER BY SUM(NumberKilled) DESC;

Insight: the ranking by volume and the ranking by fatalities do not tell the same story. A city may have many crashes, while another may have fewer crashes but greater severity.

This was the first important analytical shift: moving from counting events to measuring severity.

14. Fatality rate by city

To reduce the bias of volume, fatality rate was calculated. A minimum threshold was used to avoid distortions caused by very small samples.

SELECT TOP 20
    City_Name,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        NULLIF(COUNT(*),0)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
GROUP BY City_Name
HAVING COUNT(*) > 100
ORDER BY FatalityRatePct DESC;
CityCrashesKilledFatality %
Madera23462.56%
Signal Hill12332.44%
Chico14132.13%
Palm Springs16531.82%
Victorville67471.04%
Pomona82780.97%
Los Angeles9,833900.92%

Assessment: Los Angeles dominates in volume, but some smaller cities show higher relative rates. This teaches a key business rule: volume and risk are not the same.

15. Collision type: volume, fatality, and severity

SELECT TOP 20
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    SUM(NumberInjured) AS TotalInjured
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Description
ORDER BY SUM(NumberKilled) DESC;
Collision TypeCrashesKilledInjured
VEHICLE/PEDESTRAIN3,8921403,704
HIT OBJECT24,5601079,064
HEAD-ON5,537625,224
BROADSIDE23,2736021,845
OVERTURNED2,848282,403
REAR END41,7332725,745
OTHER4,145151,849
SIDE SWIPE13,129119,163

Finding: REAR END is the most frequent type, but VEHICLE/PEDESTRAIN concentrates more deaths with less volume. That difference is analytical gold.

Fatality Severity Index

SELECT
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        COUNT(*)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Description
ORDER BY FatalityRatePct DESC;
Collision TypeFatality Rate
VEHICLE/PEDESTRAIN3.60%
HEAD-ON1.12%
OVERTURNED0.98%
HIT OBJECT0.44%
OTHER0.36%
BROADSIDE0.26%
REAR END0.06%
SIDE SWIPE0.04%

Executive insight: in this 2026 slice, a VEHICLE/PEDESTRAIN crash was approximately 60 times more lethal than a REAR END crash.

16. Real data quality issue: PEDESTRAIN

During the analysis, a data quality lesson appeared: the field value did not say PEDESTRIAN, but PEDESTRAIN. That is why filtering by text returned no results. The professional solution was to filter by code.

SELECT DISTINCT
    Collision_Type_Code,
    Collision_Type_Description,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
GROUP BY Collision_Type_Code, Collision_Type_Description
ORDER BY SUM(NumberKilled) DESC;

Business rule: use Collision_Type_Code = 'G' to represent VEHICLE/PEDESTRAIN and avoid relying on the misspelled text.

17. Pedestrian crashes by city

SELECT TOP 20
    City_Name,
    COUNT(*) AS PedestrianCrashes,
    SUM(NumberKilled) AS PedestrianKilled
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY City_Name
ORDER BY SUM(NumberKilled) DESC;
CityPedestrian CrashesKilled
Los Angeles43240
Unincorporated52122
Victorville344
San Francisco1614
Lancaster244

Insight: Los Angeles had fewer pedestrian crashes than Unincorporated, but almost twice the pedestrian fatalities. That suggests pedestrian crash severity is not determined only by volume.

18. Fatality rate in pedestrian crashes

SELECT TOP 20
    City_Name,
    COUNT(*) AS PedestrianCrashes,
    SUM(NumberKilled) AS PedestrianKilled,
    CAST(SUM(NumberKilled) * 100.0 / COUNT(*) AS DECIMAL(10,2)) AS PedestrianFatalityRate
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY City_Name
HAVING COUNT(*) >= 10
ORDER BY PedestrianFatalityRate DESC;
CityPedestrian CrashesKilledFatality %
Madera10220.00%
Lancaster24416.67%
Eureka17211.76%
Victorville34411.76%
Richmond18211.11%
Los Angeles432409.26%

Analytical note: cities with few cases can show very high rates. Thresholds such as HAVING COUNT(*) >= 50 or >= 100 should be used for more stable analysis.

19. Lighting in pedestrian crashes

The question evolved: if pedestrian crashes are the most lethal, what role does lighting play?

SELECT
    LightingDescription,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY LightingDescription
ORDER BY SUM(NumberKilled) DESC;
LightingCrashesKilled
DARK-STREET LIGHTS1,27069
DAYLIGHT2,20833
DARK-NO STREET LIGHTS20627
DARK-STREET LIGHTS NOT FUNCTIONING217
DUSK-DAWN1624

Fatality rate by lighting condition

SELECT
    LightingDescription,
    COUNT(*) AS TotalCrashes,
    SUM(NumberKilled) AS TotalKilled,
    CAST(
        SUM(NumberKilled) * 100.0 /
        COUNT(*)
        AS DECIMAL(10,2)
    ) AS FatalityRatePct
FROM dbo.Crashes_2026
WHERE Collision_Type_Code = 'G'
GROUP BY LightingDescription
ORDER BY FatalityRatePct DESC;
Lighting ConditionFatality Rate
DARK-STREET LIGHTS NOT FUNCTIONING33.33%
DARK-NO STREET LIGHTS13.11%
DARK-STREET LIGHTS5.43%
DUSK-DAWN2.47%
DAYLIGHT1.49%

Main finding: pedestrian crashes in darkness with no street lights showed a much higher fatality rate than pedestrian crashes in daylight. In this slice, DARK-NO STREET LIGHTS was almost 9 times more lethal than DAYLIGHT.

20. Executive assessment of the analytical journey

The real value of the session was not installing Python or loading a CSV. The value was completing a full cycle of analytical thinking:

QuestionDataValidationETLKPIsNormalizationInsightsHypotheses

Findings generated

  • Rear End is very frequent, but has low relative mortality.
  • Vehicle/Pedestrian is less frequent, but it is the most lethal type.
  • Head-On and Overturned show high relative severity.
  • The ranking by volume is not the same as the ranking by risk.
  • Los Angeles concentrates many pedestrian fatalities.
  • Darkness, especially without street lighting, appears as a critical severity factor in pedestrian crashes.
  • The textual error PEDESTRAIN demonstrated the importance of using codes when descriptions are not reliable.

The master rule

It is not enough to count crashes. We must measure severity, normalize by volume, separate frequency from risk, and keep asking questions until the data starts explaining the problem.

21. What remains for the next session

3M+
Load all years into dbo.Crashes
2019-2026
Historical trends
Power BI
Executive dashboard
ML
Forecasting / Prediction

Proposed next script: load_all_years.py

The natural next step is to load the 8 files into one final table, dbo.Crashes, adding a derived Source_Year column or using the crash date itself for time analysis.

22. Conclusion

This lab demonstrated that Python can work as a real local ETL tool: it reads large files, processes them by chunks, normalizes columns, loads SQL Server, and enables immediate analysis.

But the most important lesson was different: Python was not the goal; Python was the vehicle.

The real objective was transforming open data into knowledge using a combination of business experience, analytical rules, SQL, critical thinking, and AI as a partner.

Learning by DoingPythonSQL ServerOpen DataETLBusiness IntelligenceTraffic Safety Analytics