0. Cybersecurity & Production Warning
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.
Fuente abierta: California Crash Reporting System (CCRS), publicado en el California Open Data Portal.
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
3. Proceso interactivo limpio, paso a paso
Validar Python
Primero se confirmó que Python estaba instalado y disponible desde PowerShell.
python --versionInstalar librerías necesarias
Se instalaron las librerías base para leer CSV, conectar SQL Server y cargar datos.
pip install pandas pyodbc sqlalchemyVerificar 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;
GO4. 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")
| File | Rows | Columns |
|---|---|---|
| crashes_2019.csv | 478,189 | 74 |
| crashes_2020.csv | 374,757 | 74 |
| crashes_2021.csv | 422,980 | 74 |
| crashes_2022.csv | 405,249 | 74 |
| crashes_2023.csv | 409,815 | 74 |
| crashes_2024.csv | 416,917 | 74 |
| crashes_2025.csv | 398,266 | 74 |
| crashes_2026.csv | 137,524 | 74 |
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}")
| Original | Clean |
|---|---|
| Crash Date Time | Crash_Date_Time |
| City Name | City_Name |
| Collision Type Description | Collision_Type_Description |
| Primary Collision Factor Code | Primary_Collision_Factor_Code |
| Weather 1 | Weather_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())
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:,}")
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;
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;
| City | Total Crashes |
|---|---|
| Unincorporated | 41,652 |
| Los Angeles | 9,833 |
| San Diego | 3,162 |
| Sacramento | 2,357 |
| San Jose | 2,051 |
| San Francisco | 2,007 |
| Santa Ana | 1,902 |
| Anaheim | 1,901 |
| Riverside | 1,467 |
| Stockton | 1,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;
| City | Crashes | Killed | Fatality % |
|---|---|---|---|
| Madera | 234 | 6 | 2.56% |
| Signal Hill | 123 | 3 | 2.44% |
| Chico | 141 | 3 | 2.13% |
| Palm Springs | 165 | 3 | 1.82% |
| Victorville | 674 | 7 | 1.04% |
| Pomona | 827 | 8 | 0.97% |
| Los Angeles | 9,833 | 90 | 0.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 Type | Crashes | Killed | Injured |
|---|---|---|---|
| VEHICLE/PEDESTRAIN | 3,892 | 140 | 3,704 |
| HIT OBJECT | 24,560 | 107 | 9,064 |
| HEAD-ON | 5,537 | 62 | 5,224 |
| BROADSIDE | 23,273 | 60 | 21,845 |
| OVERTURNED | 2,848 | 28 | 2,403 |
| REAR END | 41,733 | 27 | 25,745 |
| OTHER | 4,145 | 15 | 1,849 |
| SIDE SWIPE | 13,129 | 11 | 9,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 Type | Fatality Rate |
|---|---|
| VEHICLE/PEDESTRAIN | 3.60% |
| HEAD-ON | 1.12% |
| OVERTURNED | 0.98% |
| HIT OBJECT | 0.44% |
| OTHER | 0.36% |
| BROADSIDE | 0.26% |
| REAR END | 0.06% |
| SIDE SWIPE | 0.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;
| City | Pedestrian Crashes | Killed |
|---|---|---|
| Los Angeles | 432 | 40 |
| Unincorporated | 521 | 22 |
| Victorville | 34 | 4 |
| San Francisco | 161 | 4 |
| Lancaster | 24 | 4 |
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;
| City | Pedestrian Crashes | Killed | Fatality % |
|---|---|---|---|
| Madera | 10 | 2 | 20.00% |
| Lancaster | 24 | 4 | 16.67% |
| Eureka | 17 | 2 | 11.76% |
| Victorville | 34 | 4 | 11.76% |
| Richmond | 18 | 2 | 11.11% |
| Los Angeles | 432 | 40 | 9.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;
| Lighting | Crashes | Killed |
|---|---|---|
| DARK-STREET LIGHTS | 1,270 | 69 |
| DAYLIGHT | 2,208 | 33 |
| DARK-NO STREET LIGHTS | 206 | 27 |
| DARK-STREET LIGHTS NOT FUNCTIONING | 21 | 7 |
| DUSK-DAWN | 162 | 4 |
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 Condition | Fatality Rate |
|---|---|
| DARK-STREET LIGHTS NOT FUNCTIONING | 33.33% |
| DARK-NO STREET LIGHTS | 13.11% |
| DARK-STREET LIGHTS | 5.43% |
| DUSK-DAWN | 2.47% |
| DAYLIGHT | 1.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:
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
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
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 source: California Crash Reporting System (CCRS), published through the California Open Data Portal.
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
3. Clean interactive process, step by step
Validate Python
The first step was confirming that Python was installed and available from PowerShell.
python --versionInstall required libraries
The base libraries were installed to read CSV files, connect to SQL Server, and load data.
pip install pandas pyodbc sqlalchemyVerify 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;
GO4. 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")
| File | Rows | Columns |
|---|---|---|
| crashes_2019.csv | 478,189 | 74 |
| crashes_2020.csv | 374,757 | 74 |
| crashes_2021.csv | 422,980 | 74 |
| crashes_2022.csv | 405,249 | 74 |
| crashes_2023.csv | 409,815 | 74 |
| crashes_2024.csv | 416,917 | 74 |
| crashes_2025.csv | 398,266 | 74 |
| crashes_2026.csv | 137,524 | 74 |
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}")
| Original | Clean |
|---|---|
| Crash Date Time | Crash_Date_Time |
| City Name | City_Name |
| Collision Type Description | Collision_Type_Description |
| Primary Collision Factor Code | Primary_Collision_Factor_Code |
| Weather 1 | Weather_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())
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:,}")
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;
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;
| City | Total Crashes |
|---|---|
| Unincorporated | 41,652 |
| Los Angeles | 9,833 |
| San Diego | 3,162 |
| Sacramento | 2,357 |
| San Jose | 2,051 |
| San Francisco | 2,007 |
| Santa Ana | 1,902 |
| Anaheim | 1,901 |
| Riverside | 1,467 |
| Stockton | 1,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;
| City | Crashes | Killed | Fatality % |
|---|---|---|---|
| Madera | 234 | 6 | 2.56% |
| Signal Hill | 123 | 3 | 2.44% |
| Chico | 141 | 3 | 2.13% |
| Palm Springs | 165 | 3 | 1.82% |
| Victorville | 674 | 7 | 1.04% |
| Pomona | 827 | 8 | 0.97% |
| Los Angeles | 9,833 | 90 | 0.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 Type | Crashes | Killed | Injured |
|---|---|---|---|
| VEHICLE/PEDESTRAIN | 3,892 | 140 | 3,704 |
| HIT OBJECT | 24,560 | 107 | 9,064 |
| HEAD-ON | 5,537 | 62 | 5,224 |
| BROADSIDE | 23,273 | 60 | 21,845 |
| OVERTURNED | 2,848 | 28 | 2,403 |
| REAR END | 41,733 | 27 | 25,745 |
| OTHER | 4,145 | 15 | 1,849 |
| SIDE SWIPE | 13,129 | 11 | 9,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 Type | Fatality Rate |
|---|---|
| VEHICLE/PEDESTRAIN | 3.60% |
| HEAD-ON | 1.12% |
| OVERTURNED | 0.98% |
| HIT OBJECT | 0.44% |
| OTHER | 0.36% |
| BROADSIDE | 0.26% |
| REAR END | 0.06% |
| SIDE SWIPE | 0.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;
| City | Pedestrian Crashes | Killed |
|---|---|---|
| Los Angeles | 432 | 40 |
| Unincorporated | 521 | 22 |
| Victorville | 34 | 4 |
| San Francisco | 161 | 4 |
| Lancaster | 24 | 4 |
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;
| City | Pedestrian Crashes | Killed | Fatality % |
|---|---|---|---|
| Madera | 10 | 2 | 20.00% |
| Lancaster | 24 | 4 | 16.67% |
| Eureka | 17 | 2 | 11.76% |
| Victorville | 34 | 4 | 11.76% |
| Richmond | 18 | 2 | 11.11% |
| Los Angeles | 432 | 40 | 9.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;
| Lighting | Crashes | Killed |
|---|---|---|
| DARK-STREET LIGHTS | 1,270 | 69 |
| DAYLIGHT | 2,208 | 33 |
| DARK-NO STREET LIGHTS | 206 | 27 |
| DARK-STREET LIGHTS NOT FUNCTIONING | 21 | 7 |
| DUSK-DAWN | 162 | 4 |
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 Condition | Fatality Rate |
|---|---|
| DARK-STREET LIGHTS NOT FUNCTIONING | 33.33% |
| DARK-NO STREET LIGHTS | 13.11% |
| DARK-STREET LIGHTS | 5.43% |
| DUSK-DAWN | 2.47% |
| DAYLIGHT | 1.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:
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
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