SQL in Python with Pandas: The Complete Workflow (2026)
SQL in Python with Pandas: The Complete Workflow — SQLAlchemy, read_sql, and to_sql safely

🐍 SQL Cluster — 15 of 15 · Final Article

SQL in Python with Pandas: The Complete Workflow (2026)

SQL in Python with pandas is the workflow almost every data scientist actually lives in day to day — not SQL alone, not pandas alone, but the two connected. A common early mistake: pull an entire 10-million-row table into a DataFrame with pd.read_sql("SELECT * FROM orders", conn), then filter it down to a few thousand rows using pandas. The laptop's memory spikes, the notebook freezes, and the filtering that Postgres could have done in milliseconds happens instead, slowly, after every unnecessary row already crossed the network.

This final article in the cluster covers connecting Python to a real database, loading and writing data safely and efficiently, and the single biggest performance decision in this workflow: what to filter in SQL versus what to do in pandas.

by Khalid Hussain Published Aug 1, 2026 🕐 14 min read

SQL in Python with Pandas: Quick Answer

▶ Direct Answer

Using SQL in Python with pandas means connecting to a database with SQLAlchemy, then using pandas.read_sql() to load query results directly into a DataFrame, and DataFrame.to_sql() to write results back. The core discipline that separates a fast workflow from a slow one: push filtering and aggregation into the SQL query itself, and only pull the rows Python actually needs — never load an entire table and filter it in pandas afterward.

SQL in Python with pandas: the read_sql and to_sql workflow SQL Database Postgres / MySQL SQLAlchemy create_engine() pandas DataFrame connect read_sql to_sql
The full loop: connect once, read filtered results in, write processed results back out.
1
engine, created once, reused across every query in a script
SQLAlchemy's create_engine()
0
string-formatted values allowed in a query — ever
Parameterized queries only
15
of 15 — this closes out the entire SQL for Data Science cluster
Final article

The 10-Million-Row Freeze

A common early mistake looks almost reasonable: df = pd.read_sql("SELECT * FROM orders", conn), followed by df[df['order_date'] >= '2026-01-01'] to filter down to what's actually needed. The query runs. The laptop's memory climbs as ten million rows load into a DataFrame. The notebook freezes for a full minute before the filter — which needed maybe 2% of those rows — finally runs.

The fix isn't a pandas trick. It's moving the WHERE clause into the SQL query itself, so the database filters before sending anything across the network: pd.read_sql("SELECT * FROM orders WHERE order_date >= '2026-01-01'", conn). Same result, a fraction of the data moved, no freeze. This one habit — filter in SQL, not after loading — is the single highest-leverage lesson in this article.

Connecting With SQLAlchemy

SQLAlchemy provides a consistent connection interface across different databases, so the same pandas code works whether the target is PostgreSQL, MySQL, or another supported database.

pythonCONNECT.PY
from sqlalchemy import create_engine
import pandas as pd

# PostgreSQL connection string
engine = create_engine("postgresql://user:password@localhost:5432/mydb")

# MySQL connection string
engine = create_engine("mysql+pymysql://user:password@localhost:3306/mydb")
Create the engine once

Build the engine a single time at the top of a script or notebook, and reuse it for every query. SQLAlchemy manages a connection pool internally, so repeatedly creating new engines wastes resources for no benefit.

Loading Data With read_sql

pandas.read_sql() runs a query and returns the result directly as a DataFrame — no manual cursor handling, no intermediate CSV export.

pythonREAD_SQL.PY
query = """
    SELECT customer_id, order_date, order_total
    FROM orders
    WHERE status = 'shipped'
"""

df = pd.read_sql(query, engine)
print(df.head())

Every technique from earlier in this cluster — WHERE, JOIN, GROUP BY, window functions — belongs inside that query string. The DataFrame that comes back should already be close to the shape needed for analysis, not raw data waiting to be reshaped in pandas.

SQL Injection and Parameterized Queries

Building a query with an f-string and a variable feels natural in Python — and it's a genuine security risk the moment any part of that variable comes from outside the script.

pythonSQL_INJECTION.PY
# DANGEROUS: never build SQL with an f-string and external input
customer_id = get_user_input()
query = f"SELECT * FROM orders WHERE customer_id = {customer_id}"
df = pd.read_sql(query, engine)

# SAFE: parameterized query — value passed separately from the query text
query = "SELECT * FROM orders WHERE customer_id = %(cust_id)s"
df = pd.read_sql(query, engine, params={"cust_id": customer_id})

The f-string version lets a malicious or malformed input change the actual structure of the query, not just its value — the textbook definition of a SQL injection vulnerability. The parameterized version passes the value through a separate channel entirely, so it's always treated as data, never as executable SQL.

⚠ This matters even in "just a script"

It's tempting to treat this as a web-application-only concern, but any script that ever accepts input from a file, an API, or a user — not just a public web form — carries the same risk. Parameterized queries cost nothing extra to write; make them the default habit, not an exception.

Writing Data Back With to_sql

The reverse direction — pushing a processed DataFrame back into a database — uses DataFrame.to_sql().

pythonTO_SQL.PY
summary_df.to_sql(
    "monthly_summary",
    engine,
    if_exists="replace",   # or 'append', or 'fail'
    index=False
)

The if_exists parameter is worth setting deliberately every time. "fail" raises an error if the table already exists — the safest default while developing. "replace" drops and recreates the table entirely. "append" adds new rows to an existing table. Leaving this at its default in a production script is a common way to accidentally overwrite data that should have been appended.

SQL vs Pandas: Where to Do the Work

This is the practical decision that determines whether a script is fast or slow, and it closes the loop on everything the query optimization article covered earlier in this cluster.

TaskDo it in SQLDo it in pandas
Filtering rowsYes — WHERE, indexedWastes memory & transfer
Aggregating (SUM, COUNT, AVG)Yes — GROUP BYFine for small results only
Joining tablesYes — indexed JOINsSlower, more memory
Statistical modeling, plottingNot SQL's jobYes — pandas/scikit-learn territory

The rule of thumb: anything the database can do with an index — filtering, joining, aggregating — belongs in the SQL query. Anything that needs Python's broader ecosystem — statistical models, visualization, machine learning — belongs in pandas, working on the already-reduced result the database handed back.

Chunking Large Result Sets

Even a properly filtered query can return more rows than comfortably fit in memory. The chunksize parameter turns read_sql into an iterator instead of a single large load.

pythonCHUNKING.PY
for chunk in pd.read_sql("SELECT * FROM events", engine, chunksize=50000):
    process(chunk)  # handle 50,000 rows at a time

Each chunk is processed and can be discarded before the next one loads, keeping peak memory usage bounded regardless of how large the total result set is — the standard pattern for working with query results too large to load all at once.

Mistakes That Give Away a Beginner

  • SELECT * then filtering in pandas. The exact trap from this article's opening — push the WHERE clause into SQL instead.
  • Building queries with f-strings and external input. A genuine SQL injection risk — use parameterized queries instead, every time.
  • Leaving if_exists at its default in to_sql. An easy way to accidentally overwrite or fail against an existing table in production.
  • Creating a new engine for every query instead of reusing one connection pool across a script.
  • Loading an entire large table without chunksize and running into an out-of-memory error that chunking would have avoided entirely.

Practice: Build the Workflow Yourself

Using a local PostgreSQL database with an orders table, try building this end to end:

1

Connect and filter in SQL

Create an engine, then load only shipped orders from the last 90 days directly via the query — not by filtering afterward.

2

Parameterize a dynamic filter

Rewrite the query to accept a customer_id as a parameter instead of an f-string.

3

Summarize and write back

Aggregate the result with pandas, then write it to a new table with to_sql using an explicit if_exists value.

Frequently Asked Questions

How do you run a SQL query in Python with pandas?

Create a database connection with SQLAlchemy's create_engine, then pass a SQL query string and that engine to pandas.read_sql(), which returns the query results directly as a DataFrame. This is the standard way to load data in Python without exporting to a CSV first.

Is it safe to build SQL queries with Python f-strings?

No, not when any part of the query includes external or user-provided input. String-formatting a value directly into SQL text creates a SQL injection risk. Parameterized queries, where values are passed separately from the query text, are the safe standard, and pandas.read_sql supports them directly through its params argument.

Should I filter data in SQL or in pandas?

Filter and aggregate in SQL whenever possible, and pull only the resulting rows into pandas. Pulling an entire table into memory and then filtering with pandas wastes both memory and network transfer, especially on large tables, when the database could have done the filtering far more efficiently before sending any data at all.

How do you write a pandas DataFrame back to a SQL database?

Use DataFrame.to_sql(), passing a table name and the SQLAlchemy engine. The if_exists parameter controls what happens if the table already exists — fail, replace it entirely, or append new rows — and should be set deliberately rather than left at its default.

How do you handle a SQL query that returns millions of rows in pandas?

Use the chunksize parameter in pandas.read_sql, which returns an iterator of smaller DataFrames instead of loading the entire result into memory at once. This lets a script process a very large result set in manageable pieces rather than risking an out-of-memory error.

Conclusion: Cluster Complete

The core habit from this article carries the whole cluster's philosophy forward: let SQL do what SQL is good at — filtering, joining, aggregating with an index behind it — and let pandas do what pandas is good at, working on the already-reduced result. Parameterize every dynamic query, set if_exists deliberately, and chunk anything too large to hold in memory at once.

That's the fifteenth and final article in this cluster — from a single SELECT statement all the way to running SQL safely and efficiently inside a real Python workflow.

🎉 You've completed the SQL for Data Science cluster

Fundamentals, intermediate techniques, advanced patterns, and now the applied & career tier — all 15 articles. Head back to the pillar page for the full roadmap, or revisit any article for a refresher.

▶  Back to the SQL for Data Science Pillar