How to Push a Report Into a SQL Join Query Instead of Python
A report endpoint that runs SELECT * then filters the rows in a Python loop takes 10+ seconds on a 10,000-row table and times out. The fix is to push the work into a SQL join query: let PostgreSQL apply the WHERE filter and LIMIT so it returns only the rows you asked for. Response time drops from 10s+ to roughly 50ms.
Why filtering in Python is slow
The broken /reports endpoint pulls the entire table into memory, then filters and
limits in a Python loop:
cur.execute("SELECT * FROM analytics") # all 10,000 rows over the wire
all_rows = cur.fetchall()
category = request.args.get('category')
limit = request.args.get('limit', type=int)
results = []
for row in all_rows:
time.sleep(0.001) # per-row processing overhead
record = {...}
if category and row["category"] != category:
continue
results.append(record)
if limit:
results = results[:limit] # limit applied AFTER the work
Two things kill it. First, SELECT * transfers all 10,000 rows even when the caller
asked for limit=100. Second, the WHERE and LIMIT happen in Python after every
row has already been fetched and processed - so the limit saves nothing. At roughly
1ms per row that is 10 seconds of pure overhead, and the endpoint times out.
The database is a query engine built for exactly this. The fix is to push the filter and limit into the SQL query so PostgreSQL returns only the rows you need.
Push the filter and limit into the SQL query
Read the query parameters first, then build the query so category becomes a WHERE
clause and limit becomes a LIMIT. Parameterize both with %s placeholders so
psycopg2 escapes the values - never string-format user input into SQL:
@app.route('/reports')
def get_reports():
category = request.args.get('category')
limit = request.args.get('limit', type=int)
conn = get_db()
cur = conn.cursor()
sql = "SELECT id, event, category, value, created_at FROM analytics"
params = []
if category:
sql += " WHERE category = %s"
params.append(category)
sql += " ORDER BY id"
if limit:
sql += " LIMIT %s"
params.append(limit)
cur.execute(sql, params)
rows = cur.fetchall()
cur.close()
conn.close()
results = [{
"id": r["id"],
"event": r["event"],
"category": r["category"],
"value": float(r["value"]),
"created_at": str(r["created_at"]),
} for r in rows]
return jsonify({"reports": results, "count": len(results)})
Now GET /reports?limit=100 sends SELECT ... LIMIT 100 to Postgres, which returns
100 rows, not 10,000. The Python-side time.sleep() loop is gone entirely - there is
nothing to iterate over except the rows you actually want. The same query joins the
category filter into the WHERE clause, so ?category=sales&limit=50 returns only
sales rows.
Verify the speedup
Time both requests from the workspace terminal:
# The slow path timed out; now it should return in tens of milliseconds
time curl -s "http://localhost:5000/reports?limit=100" | python3 -m json.tool | head
# Category filter is applied in SQL, not Python
curl -s "http://localhost:5000/reports?category=sales&limit=50" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"
A GET /reports?limit=100 that took 10s+ now returns in around 50ms. If you want to
confirm Postgres is doing the work, prefix the query with EXPLAIN ANALYZE in psql -
the LIMIT node caps the scan, and with an index on category the WHERE filter
uses an index scan instead of reading the whole table.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Rewriting a Python filter loop as a parameterized SQL query with WHERE and LIMIT
- Pushing pagination and filtering down to PostgreSQL instead of the application
- Timing endpoints with curl and confirming the plan with EXPLAIN ANALYZE
FAQ
Should I filter query results in SQL or in Python?
Filter in SQL. The database is a query engine optimized to apply WHERE, LIMIT, and JOIN across large row sets efficiently. Fetching every row and filtering in a Python loop transfers and decodes data you throw away, which is the single most common cause of a slow endpoint.
How do I add a LIMIT to a query safely in psycopg2?
Append " LIMIT %s" to the SQL string and pass the value in the params list to cur.execute(sql, params). psycopg2 substitutes and escapes the value, so a user-supplied limit cannot inject SQL. Never build the query with f-strings or string concatenation of the raw value.
Why does my report endpoint time out on a large table?
Almost always because it runs SELECT * with no LIMIT and filters the rows in application code. On a 10,000-row table with per-row processing that is 10+ seconds. Pushing the WHERE and LIMIT into the SQL query returns only the rows you need and drops the response to milliseconds.
What is query pushdown?
Query pushdown means moving filtering, joining, and limiting from your application code into the SQL query so the database does the work close to the data. Instead of fetching everything and slicing in Python, you send WHERE, JOIN, and LIMIT clauses and let PostgreSQL return the final result set.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →