Database and SQL Interface - Python

Quote

“Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won’t usually need your flowcharts; they’ll be obvious.”

Fred Brooks, The Mythical Man-Month (1975)


import pandas as pd
import polars as pl
import polars.selectors as cs
import numpy as np
from pathlib import Path
 
from IPython.display import display, Markdown
html_formatter = get_ipython().display_formatter.formatters['text/html'] # type: ignore
html_formatter.for_type(pd.DataFrame, lambda df: df.to_html())
html_formatter.for_type(pd.Series, lambda s: s.to_frame().to_html())
 
pl.Config.set_tbl_rows(100)
pd.set_option("display.max_rows", 100)
 
DATA = Path("../data")
 
# Core datasets
ohlcv_pd = pd.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")  # 66K rows, daily OHLCV
ohlcv_pl = pl.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
dim_pd = pd.read_parquet(DATA / "index_dim.parquet")            # 169 rows, stock metadata
dim_pl = pl.read_parquet(DATA / "index_dim.parquet")
scores_pd = pd.read_parquet(DATA / "scores_daily.parquet")      # 466 rows, composite scores
scores_pl = pl.read_parquet(DATA / "scores_daily.parquet")
 
print(f"OHLCV: {ohlcv_pd.shape}, Dim: {dim_pd.shape}, Scores: {scores_pd.shape}")
import duckdb
import tempfile
import time
import shutil
import os
from dotenv import load_dotenv
from urllib.parse import quote_plus
import pyodbc
import sqlalchemy as sa

OHLCV: (66355, 12), Dim: (169, 26), Scores: (466, 36)

Polars SQLContext

Register Polars DataFrames as virtual SQL tables and query them with standard SQL. pl.SQLContext compiles SQL into Polars lazy expressions — no data copy, no network roundtrip. Results are returned as LazyFrame and must be materialized with .collect().

pl.SQLContext is Python-only — no C# equivalent in Polars.NET 0.4.0

The .NET bindings (Polars.NET 0.4.0) do not implement SQLContext. C# code must use the Polars expression API directly, or route SQL through DuckDB.NET. See 09_cs_database_interface for the DuckDB.NET approach.

Basic SELECT

Polars | Register tables in SQLContext and query with SELECT

Register DataFrames as named tables in a SQLContext instance, then run SQL against them. The execute() method returns a LazyFrame; call .collect() to materialize rows into a DataFrame.

Registers three DataFrames (ohlcv, dim, scores) as named virtual tables, then executes SELECT * FROM ohlcv LIMIT 5 — materializing 5 OHLCV rows with all 12 columns as a Polars DataFrame.

ctx=pl.SQLContext(ohlcv=ohlcv_pl, dim=dim_pl, scores=scores_pl)
display(ctx.execute("SELECT * FROM ohlcv LIMIT 5").collect())
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
i64strdatef64f64f64f64f64i64f64f64bool
21160ABI.BR2021-01-0458.1558.8556.7857.2153.576115139370.00.0false
21161ABI.BR2021-01-0556.957.9856.7557.1853.54813827220.00.0false
21162ABI.BR2021-01-0657.9658.9457.3958.7755.03713702040.00.0false
21163ABI.BR2021-01-0758.6858.8657.8858.454.690514699110.00.0false
21164ABI.BR2021-01-0858.1658.457.4357.8654.184814286810.00.0false

GROUP BY Aggregation

Polars | GROUP BY with AVG and COUNT across all symbols

Aggregate over all rows grouped by a key column using standard GROUP BY ... ORDER BY SQL. The query runs against the registered ohlcv table and returns per-symbol statistics across the full date range.

Groups 66,355 OHLCV rows by symbol, computes average close price and trading-day count per stock, and returns the top-10 by average close — confirming Hermès (RMS.PA, €1,761) and Adyen (ADYEN.AS, €1,545) as the highest-priced index constituents.

display(ctx.execute("""
    SELECT symbol, AVG(close) as avg_close, COUNT(*) as days
    FROM ohlcv
    GROUP BY symbol
    ORDER BY avg_close DESC
    LIMIT 10
""").collect())
symbolavg_closedays
strf64u32
RMS.PA1761.5557481331
ADYEN.AS1545.9764091331
ASML.AS671.3489111331
MC.PA662.4045081331
RHM.DE544.6615331324
ARGX.BR413.6919611331
OR.PA377.5443651331
MUV2.DE374.6599321324
RACE.MI289.7538231321
ALV.DE252.1937311324

JOIN Queries

Polars | INNER JOIN with WHERE filter on country

Join two registered tables on a shared key using JOIN ... USING (column). Equivalent to a Polars expression join(dim_pl, on="symbol"), but expressed in SQL for readability when combining many tables or when porting SQL from another system.

Joins ohlcv and dim on symbol, filters to German-listed stocks only, and returns the top-10 closing prices — demonstrating that Rheinmetall (RHM.DE) holds the highest recorded close (€1,988.50) in the dataset.

display(ctx.execute("""
    SELECT ohlcv.symbol, dim.short_name, dim.sector, ohlcv.close
    FROM ohlcv
    JOIN dim USING (symbol)
    WHERE dim.country = 'Germany'
    ORDER BY ohlcv.close DESC
    LIMIT 10
""").collect())
symbolshort_namesectorclose
strstrstrf64
RHM.DERHEINMETALL AGIndustrials1988.5
RHM.DERHEINMETALL AGIndustrials1988.5
RHM.DERHEINMETALL AGIndustrials1986.0
RHM.DERHEINMETALL AGIndustrials1978.5
RHM.DERHEINMETALL AGIndustrials1978.5
RHM.DERHEINMETALL AGIndustrials1962.5
RHM.DERHEINMETALL AGIndustrials1962.5
RHM.DERHEINMETALL AGIndustrials1960.5
RHM.DERHEINMETALL AGIndustrials1951.0
RHM.DERHEINMETALL AGIndustrials1950.0

Window Functions in SQL

The SQL syntax used in Polars SQLContext follows the same patterns as sql-fundamentals for SQL Server and bq-fundamentals for BigQuery. For direct Python database access with pyodbc and SQLAlchemy outside of DataFrames, see 16_py_database.

ROWS BETWEEN is unsupported

Polars SQLContext does not implement the ROWS BETWEEN N PRECEDING AND CURRENT ROW frame specification as of Polars 1.x. Sliding-window SQL written for DuckDB, PostgreSQL, or SQL Server therefore does not port over directly.

[!success] Use rolling expressions or DuckDB

For moving averages and other bounded windows, switch to the Polars expression API (pl.col().rolling_mean(window_size)). When you specifically need full SQL frame semantics, route the query through DuckDB instead of pl.SQLContext.

SQL and Expression Window Functions

Polars | Cumulative average with SQL OVER(PARTITION BY ORDER BY)

Compute a cumulative average over all rows up to and including the current row, partitioned by symbol and ordered by date, using AVG(col) OVER (PARTITION BY ... ORDER BY ...). This form of window function is supported in pl.SQLContext; ROWS BETWEEN frame specifications are not.

Computes a running cumulative average of ASML.AS close prices partitioned by symbol and ordered by date — confirming that with ORDER BY but no ROWS BETWEEN frame, the window expands to include all prior rows, yielding the dataset-wide average (671.35) at every position.

result = (
    ctx.execute("""
        SELECT symbol, date, close,
            AVG(close) OVER (PARTITION BY symbol ORDER BY date) as cumulative_avg
        FROM ohlcv
        WHERE symbol = 'ASML.AS'
        ORDER BY date DESC
        LIMIT 10
    """).collect()
)
display(result)
symboldateclosecumulative_avg
strdatef64f64
ASML.AS2026-03-121190.8671.348911
ASML.AS2026-03-111198.8671.348911
ASML.AS2026-03-101200.0671.348911
ASML.AS2026-03-091147.6671.348911
ASML.AS2026-03-061147.0671.348911
ASML.AS2026-03-051186.0671.348911
ASML.AS2026-03-041199.8671.348911
ASML.AS2026-03-031161.8671.348911
ASML.AS2026-03-021210.4671.348911
ASML.AS2026-02-271233.4671.348911

Polars | SMA-7 via expression API rolling_mean()

For sliding-window aggregations (e.g., a 7-day simple moving average), use pl.col().rolling_mean(window_size) via the Polars expression API. This is the preferred approach when ROWS BETWEEN SQL syntax is needed but not yet available in pl.SQLContext.

Filters ASML.AS rows, sorts chronologically, and computes a strict 7-day rolling mean on close — showing that the most recent SMA-7 (≈1,181) smooths the daily close swings visible in the sorted-descending output.

sma_result = (
    ohlcv_pl
    .filter(pl.col("symbol") == "ASML.AS")
    .sort("date")
    .with_columns(pl.col("close").rolling_mean(7).alias("sma_7"))
    .select("symbol", "date", "close", "sma_7")
    .sort("date", descending=True)
    .head(10)
)
display(sma_result)
symboldateclosesma_7
strdatef64f64
ASML.AS2026-03-121190.81181.428571
ASML.AS2026-03-111198.81177.285714
ASML.AS2026-03-101200.01178.942857
ASML.AS2026-03-091147.61183.714286
ASML.AS2026-03-061147.01195.828571
ASML.AS2026-03-051186.01216.028571
ASML.AS2026-03-041199.81227.085714
ASML.AS2026-03-031161.81234.142857
ASML.AS2026-03-021210.41247.542857
ASML.AS2026-02-271233.41251.514286

DuckDB — Embedded Analytical Database

DuckDB is an in-process OLAP database (like SQLite for analytics). It queries Pandas/Polars DataFrames in-place, reads Parquet/CSV directly, and supports full SQL including window functions, CTEs, and JSON. No server needed — runs in the notebook process.

Setup & Create Tables from Parquet

# In-memory database (default)
db = duckdb.connect()
 
# Load the same datasets as SQL Server tables — directly from Parquet
db.execute("CREATE TABLE eurostoxx50_ohlcv AS SELECT * FROM read_parquet('../data/eurostoxx50_ohlcv.parquet')")
db.execute("CREATE TABLE index_dim AS SELECT * FROM read_parquet('../data/index_dim.parquet')")
db.execute("CREATE TABLE scores_daily AS SELECT * FROM read_parquet('../data/scores_daily.parquet')")
 
# Verify
for t in ["eurostoxx50_ohlcv", "index_dim", "scores_daily"]:
    row = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()
    count = row[0] if row else 0
    print(f"{t}: {count:,} rows")

eurostoxx50_ohlcv: 66,355 rows index_dim: 169 rows scores_daily: 466 rows

Basic Queries

# SELECT with filtering and ordering
display(db.execute("""
    SELECT symbol, date, close, volume
    FROM eurostoxx50_ohlcv
    WHERE symbol = 'ASML.AS'
    ORDER BY date DESC
    LIMIT 10
""").df())
symboldateclosevolume
0ASML.AS2026-03-121190.8128223
1ASML.AS2026-03-111198.8562904
2ASML.AS2026-03-101200.0800815
3ASML.AS2026-03-091147.6689086
4ASML.AS2026-03-061147.0857271
5ASML.AS2026-03-051186.0778081
6ASML.AS2026-03-041199.8714587
7ASML.AS2026-03-031161.8941945
8ASML.AS2026-03-021210.4871267
9ASML.AS2026-02-271233.41010698
# Aggregation
display(db.execute("""
    SELECT symbol,
           COUNT(*) as days,
           ROUND(AVG(close), 2) as avg_close,
           ROUND(MIN(close), 2) as min_close,
           ROUND(MAX(close), 2) as max_close
    FROM eurostoxx50_ohlcv
    GROUP BY symbol
    ORDER BY avg_close DESC
    LIMIT 10
""").df())
symboldaysavg_closemin_closemax_close
0RMS.PA13311761.56842.602839.0
1ADYEN.AS13311545.98630.802766.0
2ASML.AS1331671.35397.451288.4
3MC.PA1331662.40437.55902.0
4RHM.DE1324544.6677.001988.5
5ARGX.BR1331413.69208.80803.0
6OR.PA1331377.54290.10456.9
7MUV2.DE1324374.66209.15610.6
8RACE.MI1321289.75154.70487.9
9ALV.DE1324252.19159.62392.7
# JOIN tables
display(db.execute("""
    SELECT d.symbol, d.short_name, d.sector, d.country,
           ROUND(AVG(o.close), 2) as avg_close,
           ROUND(AVG(o.volume), 0) as avg_volume
    FROM eurostoxx50_ohlcv o
    JOIN index_dim d USING (symbol)
    GROUP BY d.symbol, d.short_name, d.sector, d.country
    ORDER BY avg_close DESC
    LIMIT 10
""").df())
symbolshort_namesectorcountryavg_closeavg_volume
0RMS.PAHERMES INTLConsumer CyclicalFrance1761.5661333.0
1ADYEN.ASADYENTechnologyNetherlands1545.9882946.0
2ASML.ASASML HOLDINGTechnologyNetherlands671.35710046.0
3MC.PALVMHConsumer CyclicalFrance662.40419125.0
4RHM.DERHEINMETALL AGIndustrialsGermany544.66232900.0
5ARGX.BRARGENX SEHealthcareNetherlands413.6971069.0
6OR.PAL'OREALConsumer DefensiveFrance377.54363723.0
7MUV2.DEMUENCHENER RUECKVERS.-GES. AG NFinancial ServicesGermany374.66301211.0
8RACE.MIFERRARIConsumer CyclicalItaly289.75360852.0
9ALV.DEAllianz SEFinancial ServicesGermany252.19832296.0

Query DataFrames Directly (Zero-Copy)

DuckDB resolves Python variable names in the caller’s scope as SQL table references. Any Pandas DataFrame or Polars DataFrame/LazyFrame in scope can be queried by name — no CREATE TABLE or data copy needed.

Zero-copy scan: DuckDB reads Pandas/Polars DataFrames without duplicating data

When you reference ohlcv_pd in a DuckDB SQL query, DuckDB uses Apache Arrow zero-copy to scan the DataFrame’s underlying memory buffers directly. This means you can run analytical SQL against a 66K-row Pandas DataFrame without incurring a serialization/deserialization cost. Works for both Pandas (DataFrame) and Polars (DataFrame and LazyFrame).

# DuckDB can query Pandas DataFrames by variable name — no import needed
display(db.execute("""
    SELECT symbol, AVG(close) as avg_close
    FROM ohlcv_pd
    GROUP BY symbol
    ORDER BY avg_close DESC
    LIMIT 5
""").df())
symbolavg_close
0RMS.PA1761.555748
1ADYEN.AS1545.976409
2ASML.AS671.348911
3MC.PA662.404508
4RHM.DE544.661533
# Also works with Polars DataFrames
display(db.execute("""
    SELECT sector, COUNT(*) as stocks
    FROM dim_pl
    GROUP BY sector
    ORDER BY stocks DESC
""").df())
sectorstocks
0Financial Services32
1Technology26
2Energy24
3Industrials22
4Consumer Cyclical18
5Healthcare15
6Consumer Defensive12
7Communication Services12
8Basic Materials6
9Utilities2

Window Functions

# Rank stocks by avg close within each sector
display(db.execute("""
    SELECT symbol, sector, avg_close,
           RANK() OVER (PARTITION BY sector ORDER BY avg_close DESC) as sector_rank
    FROM (
        SELECT o.symbol, d.sector, ROUND(AVG(o.close), 2) as avg_close
        FROM eurostoxx50_ohlcv o
        JOIN index_dim d USING (symbol)
        GROUP BY o.symbol, d.sector
    )
    QUALIFY sector_rank <= 3
    ORDER BY sector, sector_rank
""").df())
symbolsectoravg_closesector_rank
0AI.PABasic Materials145.431
1BAS.DEBasic Materials50.562
2DTE.DECommunication Services22.431
3RMS.PAConsumer Cyclical1761.561
4MC.PAConsumer Cyclical662.402
5RACE.MIConsumer Cyclical289.753
6OR.PAConsumer Defensive377.541
7BN.PAConsumer Defensive60.292
8ABI.BRConsumer Defensive54.863
9TTE.PAEnergy53.141
10ENI.MIEnergy13.402
11MUV2.DEFinancial Services374.661
12ALV.DEFinancial Services252.192
13DB1.DEFinancial Services184.803
14ARGX.BRHealthcare413.691
15EL.PAHealthcare191.912
16SAN.PAHealthcare90.303
17RHM.DEIndustrials544.661
18SU.PAIndustrials179.032
19SAF.PAIndustrials171.833
20ADYEN.ASTechnology1545.981
21ASML.ASTechnology671.352
22SAP.DETechnology154.333
23IBE.MCUtilities12.261
24ENEL.MIUtilities6.822
# Running average and lag/lead
display(db.execute("""
    SELECT date, close,
           ROUND(AVG(close) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) as sma_7,
           ROUND(AVG(close) OVER (ORDER BY date ROWS BETWEEN 29 PRECEDING AND CURRENT ROW), 2) as sma_30,
           LAG(close, 1) OVER (ORDER BY date) as prev_close,
           ROUND((close - LAG(close, 1) OVER (ORDER BY date)) / LAG(close, 1) OVER (ORDER BY date) * 100, 2) as daily_return_pct
    FROM eurostoxx50_ohlcv
    WHERE symbol = 'ASML.AS'
    ORDER BY date DESC
    LIMIT 15
""").df())
dateclosesma_7sma_30prev_closedaily_return_pct
02026-03-121190.81181.431204.411198.8-0.67
12026-03-111198.81177.291204.451200.0-0.10
22026-03-101200.01178.941204.311147.64.57
32026-03-091147.61183.711204.891147.00.05
42026-03-061147.01195.831205.911186.0-3.29
52026-03-051186.01216.031206.951199.8-1.15
62026-03-041199.81227.091206.631161.83.27
72026-03-031161.81234.141205.131210.4-4.02
82026-03-021210.41247.541204.401233.4-1.86
92026-02-271233.41251.511201.401232.40.08
102026-02-261232.41253.141199.191288.4-4.35
112026-02-251288.41248.401196.431263.41.98
122026-02-241263.41235.061189.621249.21.14
132026-02-231249.21224.631184.261255.6-0.51
142026-02-201255.61214.711178.831238.21.41
# NTILE, PERCENT_RANK, CUME_DIST
display(db.execute("""
    SELECT symbol, avg_close,
           NTILE(4) OVER (ORDER BY avg_close) as quartile,
           ROUND(PERCENT_RANK() OVER (ORDER BY avg_close), 3) as pct_rank,
           ROUND(CUME_DIST() OVER (ORDER BY avg_close), 3) as cume_dist
    FROM (
        SELECT symbol, ROUND(AVG(close), 2) as avg_close
        FROM eurostoxx50_ohlcv
        GROUP BY symbol
    )
    ORDER BY avg_close DESC
    LIMIT 15
""").df())
symbolavg_closequartilepct_rankcume_dist
0RMS.PA1761.5641.0001.00
1ADYEN.AS1545.9840.9800.98
2ASML.AS671.3540.9590.96
3MC.PA662.4040.9390.94
4RHM.DE544.6640.9180.92
5ARGX.BR413.6940.8980.90
6OR.PA377.5440.8780.88
7MUV2.DE374.6640.8570.86
8RACE.MI289.7540.8370.84
9ALV.DE252.1940.8160.82
10ADS.DE205.4340.7960.80
11EL.PA191.9140.7760.78
12DB1.DE184.8030.7550.76
13SU.PA179.0330.7350.74
14SAF.PA171.8330.7140.72

Common Table Expressions (CTEs)

# Multi-level CTE
display(db.execute("""
    WITH daily_returns AS (
        SELECT symbol, date, close,
               (close - LAG(close) OVER (PARTITION BY symbol ORDER BY date))
               / LAG(close) OVER (PARTITION BY symbol ORDER BY date) * 100 as ret
        FROM eurostoxx50_ohlcv
    ),
    volatility AS (
        SELECT symbol,
               ROUND(STDDEV(ret), 2) as daily_vol,
               ROUND(AVG(ret), 4) as avg_ret,
               COUNT(*) as days
        FROM daily_returns
        WHERE ret IS NOT NULL
        GROUP BY symbol
    )
    SELECT v.symbol, d.sector, v.daily_vol, v.avg_ret,
           ROUND(v.daily_vol * SQRT(252), 2) as annualized_vol
    FROM volatility v
    JOIN index_dim d USING (symbol)
    ORDER BY annualized_vol DESC
    LIMIT 10
""").df())
symbolsectordaily_volavg_retannualized_vol
0ADYEN.ASTechnology3.17-0.001050.32
1ENR.DEIndustrials3.150.176250.00
2RHM.DEIndustrials2.570.249840.80
3PRX.ASConsumer Cyclical2.500.040039.69
4ARGX.BRHealthcare2.480.101539.37
5ASML.ASTechnology2.370.109137.62
6IFX.DETechnology2.350.045537.31
7VOW.DEConsumer Cyclical2.24-0.019435.56
8UCG.MIFinancial Services2.240.189135.56
9ADS.DEConsumer Cyclical2.17-0.033034.45

Recursive CTE

# Generate a date series using recursive CTE
display(db.execute("""
    WITH RECURSIVE dates AS (
        SELECT DATE '2024-01-01' as dt
        UNION ALL
        SELECT dt + INTERVAL 1 DAY FROM dates WHERE dt < DATE '2024-01-10'
    )
    SELECT dt, DAYNAME(dt) as day_name FROM dates
""").df())
dtday_name
02024-01-01Monday
12024-01-02Tuesday
22024-01-03Wednesday
32024-01-04Thursday
42024-01-05Friday
52024-01-06Saturday
62024-01-07Sunday
72024-01-08Monday
82024-01-09Tuesday
92024-01-10Wednesday

Read Files Directly (No Import Step)

# Query Parquet files without loading into memory
display(db.execute("""
    SELECT symbol, date, close
    FROM read_parquet('../data/eurostoxx50_ohlcv.parquet')
    WHERE symbol = 'SAP.DE'
    ORDER BY date DESC
    LIMIT 5
""").df())
symboldateclose
0SAP.DE2026-03-12166.52
1SAP.DE2026-03-11165.44
2SAP.DE2026-03-10169.60
3SAP.DE2026-03-09171.88
4SAP.DE2026-03-06172.74
# Query CSV files directly
display(db.execute("""
    SELECT *
    FROM read_csv_auto('../data/scores_daily.csv')
    LIMIT 5
""").df())
id_indexsymbolscore_datesectorpe_zscorepb_zscoreev_ebitda_zscoreyield_zscorerelative_value_scorerelative_value_rankrelative_strengthsma_50_ratiosma_200_ratiodist_from_52w_highmomentum_scoremomentum_rankimplied_upsiderecommendation_meanprice_falling_analysts_bullishsentiment_scoresentiment_rankcomposite_scorecomposite_rank_scored_atsma_30_closesma_90_closemarket_capindex_weightshort_namecountrycurrent_priceday_change_pctfive_day_change_pctytd_change_pctcurrency
0163euro_stoxx_50BNP.PA2026-03-04Financial Services0.9133891.261140NaN2.3889621.52116310.0161231.0090901.1302640.0824860.477966160.1531571.84211False0.052711250.68394712026-03-04 22:40:25.48918092.08500081.181889997512151040.019525BNP PARIBAS ACT.AFrance89.3200.011437-0.0731560.105582EUR
1168euro_stoxx_50DTE.DE2026-03-04Communication Services0.3265870.3874630.379532-0.1278670.24142924-0.2055981.1206501.1124160.0555240.68575280.1212121.33333False0.617835100.51500522026-03-04 22:40:25.48918030.83800028.5545561642943119360.032159DEUTSCHE TELEKOM AGGermany33.0000.011649-0.0196080.193059EUR
2174euro_stoxx_50IFX.DE2026-03-04Technology0.5093980.6372150.677068-0.6966620.281755220.0009651.0482441.1986260.0888450.67576490.1264081.37500False0.579187110.51223532026-03-04 22:40:25.48918043.48033338.855556572225331200.011201INFINEON TECHNOLOGIES AGGermany43.9450.054343-0.0664900.164723EUR
3172euro_stoxx_50ENR.DE2026-03-04Industrials-0.902738-0.743338-1.693212-1.326075-1.166341461.6454551.1370071.4740950.0518502.54188910.0752691.80000False-0.123264290.41742842026-03-04 22:40:25.489180155.675000129.1220001392072622080.027249Siemens Energy AGGermany162.7500.047297-0.0392560.351744EUR
4149euro_stoxx_50ABI.BR2026-03-04Consumer Defensive0.4740840.8440750.552739-0.9750050.22397325-0.0297911.0585421.1427830.0630630.651891100.1861981.69231False0.344755170.40687352026-03-04 22:40:25.48918064.34200057.8693331255661568000.024579AB INBEVBelgium64.480-0.017073-0.0407620.174499EUR
# Query multiple Parquet files with glob
display(db.execute("""
    SELECT COUNT(*) as total_rows, MIN(date) as earliest, MAX(date) as latest
    FROM read_parquet('../data/*_ohlcv.parquet')
""").df())
total_rowsearliestlatest
01561932021-01-042026-03-12

Export Results

TMP = Path(tempfile.mkdtemp())
 
# Export query result to Parquet
db.execute(f"""
    COPY (
        SELECT symbol, date, close FROM eurostoxx50_ohlcv
        WHERE symbol = 'ASML.AS'
    ) TO '{TMP}/asml.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")
print(f"Parquet: {(TMP / 'asml.parquet').stat().st_size:,} bytes")
 
# Export to CSV
db.execute(f"""
    COPY (
        SELECT symbol, date, close FROM eurostoxx50_ohlcv
        WHERE symbol = 'ASML.AS'
    ) TO '{TMP}/asml.csv' (FORMAT CSV, HEADER TRUE)
""")
print(f"CSV: {(TMP / 'asml.csv').stat().st_size:,} bytes")
 
# Export to JSON
db.execute(f"""
    COPY (
        SELECT symbol, date, close FROM eurostoxx50_ohlcv
        WHERE symbol = 'ASML.AS'
        LIMIT 5
    ) TO '{TMP}/asml.json' (FORMAT JSON)
""")
print(f"JSON: {(TMP / 'asml.json').stat().st_size:,} bytes")
print((TMP / "asml.json").read_text()[:300])

Parquet: 6,823 bytes CSV: 33,410 bytes JSON: 278 bytes {“symbol”:“ASML.AS”,“date”:“2021-01-04”,“close”:406.25} {“symbol”:“ASML.AS”,“date”:“2021-01-05”,“close”:406.9} {“symbol”:“ASML.AS”,“date”:“2021-01-06”,“close”:402.85} {“symbol”:“ASML.AS”,“date”:“2021-01-07”,“close”:403.9} {“symbol”:“ASML.AS”,“date”:“2021-01-08”,“close”:416.05}

Result Conversion: Pandas, Polars, Arrow

query = "SELECT symbol, date, close FROM eurostoxx50_ohlcv WHERE symbol = 'ASML.AS' LIMIT 5"
 
# To Pandas
df_pd = db.execute(query).df()
print(f"Pandas: {type(df_pd).__name__}, shape={df_pd.shape}")
 
# To Polars
df_pl = db.execute(query).pl()
print(f"Polars: {type(df_pl).__name__}, shape={df_pl.shape}")
 
# To Arrow
table = db.execute(query).arrow().read_all()
print(f"Arrow:  {type(table).__name__}, rows={table.num_rows}")
 
# To Python lists
rows = db.execute(query).fetchall()
print(f"Python: {len(rows)} rows, first={rows[0]}")
 
# To numpy
arr = db.execute("SELECT close FROM eurostoxx50_ohlcv WHERE symbol = 'ASML.AS' LIMIT 5").fetchnumpy()
print(f"NumPy:  {arr['close']}")

Pandas: DataFrame, shape=(5, 3) Polars: DataFrame, shape=(5, 3) Arrow: Table, rows=5 Python: 5 rows, first=(‘ASML.AS’, datetime.date(2021, 1, 4), 406.25) NumPy: [406.25 406.9 402.85 403.9 416.05]

Persistent Database (on disk)

# Create a persistent DuckDB file
db_path = TMP / "stoxx.duckdb"
pdb = duckdb.connect(str(db_path))
 
# Create tables from Parquet
pdb.execute("CREATE OR REPLACE TABLE ohlcv AS SELECT * FROM read_parquet('../data/eurostoxx50_ohlcv.parquet')")
pdb.execute("CREATE OR REPLACE TABLE dim AS SELECT * FROM read_parquet('../data/index_dim.parquet')")
print(f"Database file: {db_path.stat().st_size:,} bytes")
 
# Close and reopen — data persists
pdb.close()
pdb = duckdb.connect(str(db_path))
row_count = pdb.execute("SELECT COUNT(*) FROM ohlcv").fetchone()
if row_count:
    print(f"After reopen: {row_count[0]:,} rows")
pdb.close()

Database file: 12,288 bytes After reopen: 66,355 rows

Views & Macros

# Create a view (virtual table — query runs on access)
db.execute("""
    CREATE OR REPLACE VIEW v_stock_summary AS
    SELECT o.symbol, d.short_name, d.sector, d.country,
           COUNT(*) as days,
           ROUND(AVG(o.close), 2) as avg_close,
           ROUND(STDDEV(o.close), 2) as std_close
    FROM eurostoxx50_ohlcv o
    JOIN index_dim d USING (symbol)
    GROUP BY o.symbol, d.short_name, d.sector, d.country
""")
display(db.execute("SELECT * FROM v_stock_summary ORDER BY avg_close DESC LIMIT 5").df())
symbolshort_namesectorcountrydaysavg_closestd_close
0RMS.PAHERMES INTLConsumer CyclicalFrance13311761.56481.32
1ADYEN.ASADYENTechnologyNetherlands13311545.98417.81
2ASML.ASASML HOLDINGTechnologyNetherlands1331671.35162.75
3MC.PALVMHConsumer CyclicalFrance1331662.40103.50
4RHM.DERHEINMETALL AGIndustrialsGermany1324544.66586.08
# Create a SQL macro (reusable function)
db.execute("""
    CREATE OR REPLACE MACRO sma(col, n) AS (
        AVG(col) OVER (ORDER BY date ROWS BETWEEN (n-1) PRECEDING AND CURRENT ROW)
    )
""")
 
# Use the macro
display(db.execute("""
    SELECT date, close,
           ROUND(sma(close, 7), 2) as sma_7,
           ROUND(sma(close, 30), 2) as sma_30
    FROM eurostoxx50_ohlcv
    WHERE symbol = 'ASML.AS'
    ORDER BY date DESC
    LIMIT 10
""").df())
dateclosesma_7sma_30
02026-03-121190.81181.431204.41
12026-03-111198.81177.291204.45
22026-03-101200.01178.941204.31
32026-03-091147.61183.711204.89
42026-03-061147.01195.831205.91
52026-03-051186.01216.031206.95
62026-03-041199.81227.091206.63
72026-03-031161.81234.141205.13
82026-03-021210.41247.541204.40
92026-02-271233.41251.511201.40

JSON Functions

# DuckDB has full JSON support
display(db.execute("""
    SELECT
        json_object('symbol', symbol, 'close', close, 'date', date) as json_row,
        json_extract(json_object('symbol', symbol, 'close', close), '$.symbol') as extracted
    FROM eurostoxx50_ohlcv
    WHERE symbol = 'ASML.AS'
    ORDER BY date DESC
    LIMIT 5
""").df())
json_rowextracted
0{"symbol":"ASML.AS","close":1190.8,"date":"2026-03-12"}"ASML.AS"
1{"symbol":"ASML.AS","close":1198.8,"date":"2026-03-11"}"ASML.AS"
2{"symbol":"ASML.AS","close":1200.0,"date":"2026-03-10"}"ASML.AS"
3{"symbol":"ASML.AS","close":1147.6,"date":"2026-03-09"}"ASML.AS"
4{"symbol":"ASML.AS","close":1147.0,"date":"2026-03-06"}"ASML.AS"

String & Date Functions

display(db.execute("""
    SELECT
        symbol,
        SPLIT_PART(symbol, '.', 1) as ticker,
        SPLIT_PART(symbol, '.', 2) as exchange,
        LENGTH(symbol) as sym_len,
        UPPER(short_name) as upper_name,
        LEFT(short_name, 10) as short
    FROM index_dim
    LIMIT 10
""").df())
symboltickerexchangesym_lenupper_nameshort
0ASML.ASASMLAS7ASML HOLDINGASML HOLDI
1MC.PAMCPA5LVMHLVMH
2RMS.PARMSPA6HERMES INTLHERMES INT
3OR.PAORPA5L'OREALL'OREAL
4SAP.DESAPDE6SAP SESAP SE
5SIE.DESIEDE6SIEMENS AGSIEMENS AG
6ITX.MCITXMC6INDUSTRIA DE DISE...O TEXTIL S.INDUSTRIA
7DTE.DEDTEDE6DEUTSCHE TELEKOM AGDEUTSCHE T
8SAN.MCSANMC6BANCO SANTANDER S.A.BANCO SANT
9SU.PASUPA5SCHNEIDER ELECTRIC SESCHNEIDER
display(db.execute("""
    SELECT
        date,
        YEAR(date) as yr,
        MONTH(date) as mo,
        DAYNAME(date) as day_name,
        WEEKOFYEAR(date) as week,
        date - INTERVAL 7 DAY as week_ago,
        DATEDIFF('day', MIN(date) OVER (), date) as days_since_start
    FROM eurostoxx50_ohlcv
    WHERE symbol = 'ASML.AS'
    ORDER BY date DESC
    LIMIT 10
""").df())
dateyrmoday_nameweekweek_agodays_since_start
02026-03-1220263Thursday112026-03-051893
12026-03-1120263Wednesday112026-03-041892
22026-03-1020263Tuesday112026-03-031891
32026-03-0920263Monday112026-03-021890
42026-03-0620263Friday102026-02-271887
52026-03-0520263Thursday102026-02-261886
62026-03-0420263Wednesday102026-02-251885
72026-03-0320263Tuesday102026-02-241884
82026-03-0220263Monday102026-02-231883
92026-02-2720262Friday92026-02-201880

PIVOT & UNPIVOT

# PIVOT: rows to columns
display(db.execute("""
    PIVOT (
        SELECT d.sector, YEAR(o.date) as yr, ROUND(AVG(o.close), 2) as avg_close
        FROM eurostoxx50_ohlcv o
        JOIN index_dim d USING (symbol)
        GROUP BY d.sector, YEAR(o.date)
    )
    ON yr
    USING AVG(avg_close)
    ORDER BY sector
""").df())
sector202120222023202420252026
0Basic Materials92.4086.2695.60105.90109.07105.97
1Communication Services16.6718.0620.6824.5030.8230.17
2Consumer Cyclical308.44296.79383.32423.22424.14382.46
3Consumer Defensive125.78119.62135.95137.95132.23138.42
4Energy25.0731.9936.0737.9734.4240.00
5Financial Services64.2566.0179.9299.19125.30127.33
6Healthcare136.39157.01177.90189.54247.28265.22
7Industrials89.1392.61116.08163.54286.22321.53
8Technology599.48452.04408.80473.39506.48517.70
9Utilities9.177.898.549.4411.7914.28

Parameterized Queries

# Positional parameters with $1, $2...
result = db.execute(
    "SELECT * FROM eurostoxx50_ohlcv WHERE symbol = $1 AND close > $2 ORDER BY date DESC LIMIT 5",
    ["ASML.AS", 700.0]
).df()
display(result)
 
# Named parameters (DuckDB 0.9+)
result = db.execute(
    "SELECT * FROM eurostoxx50_ohlcv WHERE symbol = $sym ORDER BY date DESC LIMIT $n",
    {"sym": "SAP.DE", "n": 5}
).df()
display(result)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
066881ASML.AS2026-03-121194.81202.21187.81190.81190.81282230.00.0False
166733ASML.AS2026-03-111188.41210.81174.01198.81198.85629040.00.0False
266732ASML.AS2026-03-101188.41208.41172.21200.01200.08008150.00.0False
366731ASML.AS2026-03-091072.01147.61060.21147.61147.66890860.00.0False
464732ASML.AS2026-03-061186.01192.61112.81147.01147.08572710.00.0False
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
066885SAP.DE2026-03-12163.00166.74162.80166.52166.528067220.00.0False
166745SAP.DE2026-03-11167.10168.96163.02165.44165.4429537820.00.0False
266744SAP.DE2026-03-10171.60172.88166.46169.60169.6031872460.00.0False
366743SAP.DE2026-03-09173.72173.86168.52171.88171.8819908230.00.0False
464740SAP.DE2026-03-06173.66175.10170.24172.74172.7433472210.00.0False

Transactions & DDL

# Create, insert, update, delete
db.execute("CREATE OR REPLACE TABLE _test (id INTEGER, name VARCHAR, score DOUBLE)")
db.execute("INSERT INTO _test VALUES (1, 'alpha', 0.9), (2, 'beta', 0.7), (3, 'gamma', 0.5)")
display(db.execute("SELECT * FROM _test").df())
 
# Update
db.execute("UPDATE _test SET score = 0.95 WHERE name = 'alpha'")
 
# Delete
db.execute("DELETE FROM _test WHERE name = 'gamma'")
display(db.execute("SELECT * FROM _test").df())
 
# Transaction
db.execute("BEGIN TRANSACTION")
db.execute("INSERT INTO _test VALUES (4, 'delta', 0.8)")
db.execute("ROLLBACK")  # undo
row = db.execute("SELECT COUNT(*) FROM _test").fetchone()
if row:
    print(f"After rollback: {row[0]} rows")
 
db.execute("DROP TABLE _test")
idnamescore
01alpha0.9
12beta0.7
23gamma0.5
idnamescore
01alpha0.95
12beta0.70

After rollback: 2 rows

<_duckdb.DuckDBPyConnection at 0x1e428aaf430>

Performance: DuckDB vs Pandas vs Polars

# Complex analytical query
query_sql = """
    SELECT symbol, sector,
           AVG(close) as avg_close,
           STDDEV(close) as std_close,
           COUNT(*) as days
    FROM eurostoxx50_ohlcv o
    JOIN index_dim d USING (symbol)
    GROUP BY symbol, sector
    ORDER BY avg_close DESC
"""
 
# DuckDB
t0 = time.perf_counter()
df_duck = db.execute(query_sql).df()
t_duck = time.perf_counter() - t0
 
# Pandas equivalent
t0 = time.perf_counter()
df_pandas = (ohlcv_pd.merge(dim_pd[["symbol", "sector"]], on="symbol")
             .groupby(["symbol", "sector"])["close"]
             .agg(["mean", "std", "count"])
             .reset_index()
             .sort_values("mean", ascending=False))
t_pandas = time.perf_counter() - t0
 
# Polars equivalent
t0 = time.perf_counter()
df_polars = (ohlcv_pl.join(dim_pl.select("symbol", "sector"), on="symbol")
             .group_by("symbol", "sector")
             .agg(pl.col("close").mean().alias("avg_close"),
                  pl.col("close").std().alias("std_close"),
                  pl.col("close").count().alias("days"))
             .sort("avg_close", descending=True))
t_polars = time.perf_counter() - t0
 
print(f"DuckDB:  {t_duck:.3f}s")
print(f"Pandas:  {t_pandas:.3f}s")
print(f"Polars:  {t_polars:.3f}s")

DuckDB: 0.007s Pandas: 0.011s Polars: 0.003s

Schema Inspection

# List tables
display(db.execute("SHOW TABLES").df())
 
# Describe a table
display(db.execute("DESCRIBE eurostoxx50_ohlcv").df())
 
# Table sizes
display(db.execute("""
    SELECT table_name, estimated_size, column_count
    FROM duckdb_tables()
    ORDER BY estimated_size DESC
""").df())
name
0eurostoxx50_ohlcv
1index_dim
2scores_daily
3v_stock_summary
column_namecolumn_typenullkeydefaultextra
0idBIGINTYESNoneNoneNone
1symbolVARCHARYESNoneNoneNone
2dateDATEYESNoneNoneNone
3openDOUBLEYESNoneNoneNone
4highDOUBLEYESNoneNoneNone
5lowDOUBLEYESNoneNoneNone
6closeDOUBLEYESNoneNoneNone
7adj_closeDOUBLEYESNoneNoneNone
8volumeBIGINTYESNoneNoneNone
9dividendsDOUBLEYESNoneNoneNone
10stock_splitsDOUBLEYESNoneNoneNone
11is_filledBOOLEANYESNoneNoneNone
table_nameestimated_sizecolumn_count
0eurostoxx50_ohlcv6635512
1scores_daily46636
2index_dim16926
# Cleanup temp files
shutil.rmtree(TMP, ignore_errors=True)
db.execute("DROP VIEW IF EXISTS v_stock_summary")
db.execute("DROP MACRO IF EXISTS sma")
print("DuckDB cleanup done")

DuckDB cleanup done

DuckDB Summary

FeatureDuckDB
Setupduckdb.connect() (in-memory) or duckdb.connect('file.duckdb')
Query DataFramesdb.execute('SELECT * FROM df_variable') — zero-copy
Read Parquet/CSVread_parquet('path'), read_csv_auto('path') — no import step
Glob filesread_parquet('data/*.parquet')
Window functionsFull support: RANK, LAG, ROWS BETWEEN, QUALIFY
CTEsWITH ... AS, recursive CTEs
PIVOT/UNPIVOTNative PIVOT syntax
ViewsCREATE VIEW
MacrosCREATE MACRO name(args) AS (expr)
JSONjson_object(), json_extract()
Parameters$1 positional, $name named
ExportCOPY ... TO 'file' (FORMAT PARQUET/CSV/JSON)
Result to Pandas.df()
Result to Polars.pl()
Result to Arrow.arrow()
Persistent storageduckdb.connect('file.duckdb')
vs PandasOften 2–10x faster for analytical queries
vs PolarsComparable speed; DuckDB wins on complex SQL, Polars on expression API

Part 1 Summary

FeaturePolars SQLDuckDB
Setuppl.SQLContext()duckdb.connect()
InputPolars DataFramesPandas/Polars/Arrow
OutputLazyFramePandas DataFrame
SQL dialectStandard SQLPostgreSQL-like

Part 2: SQL Server Integration

Connect Pandas and Polars directly to SQL Server tables for reading, writing, and querying.

Connection Setup

Never hardcode credentials in connection strings

Use environment variables (os.environ.get()) or a secret manager. The .env file should be in .gitignore and never committed. See environment-variables for secure credential handling patterns.

Store credentials in environment variables

Load credentials at runtime via os.environ.get("DB_PASSWORD") or python-dotenv. Add .env to .gitignore and never commit it. For production, use a secret manager (GCP Secret Manager, Azure Key Vault) and inject the value as an environment variable.

TrustServerCertificate=yes disables certificate validation

Acceptable for local development. In production, use a valid TLS certificate and remove this flag — otherwise connections are vulnerable to man-in-the-middle attacks.

Use a valid TLS certificate in production

Install a trusted certificate on the SQL Server instance and remove TrustServerCertificate=yes from the connection string. Verify with openssl s_client -connect <host>:1433 before deploying.

pyodbc and SQLAlchemy Connection

load_dotenv(dotenv_path="../.env")
 
# Connection parameters
SERVER = "localhost,1434"
DATABASE = "stoxx"
USER = "sa"
PASSWORD = os.environ.get("STOXX_SA_PASSWORD", "")  # set via: $env:STOXX_SA_PASSWORD="..."
 
# pyodbc connection string
PYODBC_CONN = (
    f"DRIVER={{ODBC Driver 18 for SQL Server}};"
    f"SERVER={SERVER};"
    f"DATABASE={DATABASE};"
    f"UID={USER};"
    f"PWD={PASSWORD};"
    f"Encrypt=yes;TrustServerCertificate=yes;"
)
 
# SQLAlchemy engine (used by Pandas)
ENGINE = sa.create_engine(
    f"mssql+pyodbc:///?odbc_connect={quote_plus(PYODBC_CONN)}"
)
 
 
# Test connection
with pyodbc.connect(PYODBC_CONN) as conn:
    cursor = conn.cursor()
    cursor.execute("SELECT @@VERSION")
    version = cursor.fetchone()
    if version:
        print(version[0][:80])
print("Connection OK")

Microsoft SQL Server 2022 (RTM-CU23) (KB5078297) - 16.0.4236.2 (X64) Jan 22 20 Connection OK

Reading Tables

pd.read_sql() loads the entire result

pd.read_sql() loads the entire result set into memory SELECT * FROM table on a 10M row table allocates the full DataFrame in RAM. For large tables, use chunksize= to iterate in batches, or add a WHERE clause to limit rows. Polars pl.read_database() has the same issue — neither library supports server-side cursors by default.

Limit rows or use chunked reads

Add a WHERE clause or TOP N / LIMIT N to bound the result set. For full-table loads, iterate with chunksize=: for chunk in pd.read_sql(q, engine, chunksize=50_000). In Polars use pl.read_database(q, conn, iter_batches=True, batch_size=50_000).

SQL injection risk with string

SQL injection risk with string formatting in queries Never use f-strings for user input: f"WHERE symbol = '{user_input}'" is injectable. Use parameterized queries: pd.read_sql("SELECT * FROM t WHERE symbol = ?", engine, params=["ASML"]).

Always use parameterized queries

Pass user-supplied values as parameters, never via string formatting: pd.read_sql("SELECT * FROM t WHERE symbol = ?", engine, params=[symbol]). In SQLAlchemy use bound parameters: text("SELECT * FROM t WHERE symbol = :s") with conn.execute(stmt, {"s": symbol}).

Reading with pd.read_sql() and pl.read_database()

Pandas | pd.read_sql()

Reads the top-5 rows from bronze.eurostoxx50_ohlcv into a Pandas DataFrame via a raw SQL string, printing column dtypes — confirming datetime64[ns] for _ingested_at and object for the date column before explicit parsing.

# Read entire table
df = pd.read_sql("SELECT TOP 5 * FROM bronze.eurostoxx50_ohlcv", ENGINE)
display(df)
print(f"dtypes:\n{df.dtypes}")
id_ingested_atsymboldateopenhighlowcloseadj_closevolumedividendsstock_splits
0667282026-03-12 12:45:00.017366ASML.AS2026-03-121194.81202.201187.81190.801190.801282230.00.0
1667322026-03-12 12:45:00.017366MC.PA2026-03-12495.3497.40491.6494.35494.351719970.00.0
2667362026-03-12 12:45:00.017366RMS.PA2026-03-121900.01918.501894.01906.001906.00186810.00.0
3667402026-03-12 12:45:00.017366OR.PA2026-03-12361.1362.30357.8360.80360.80826210.00.0
4667442026-03-12 12:45:00.017366SAP.DE2026-03-12163.0166.74162.8166.52166.528067220.00.0

dtypes: id int64 _ingested_at datetime64[ns] symbol object date object open float64 high float64 low float64 close float64 adj_close float64 volume int64 dividends float64 stock_splits float64 dtype: object

# Parameterized query
symbol = "ASML.AS"
df = pd.read_sql(
    sa.text("SELECT [date], [close], volume FROM bronze.eurostoxx50_ohlcv WHERE symbol = :sym ORDER BY [date] DESC"),
    ENGINE,
    params={"sym": symbol}
)
display(df.head(10))
print(f"Shape: {df.shape}")
dateclosevolume
02026-03-121190.8128223

Shape: (1, 3)

# Read entire table (shorthand)
df = pd.read_sql_table("index_dim", ENGINE, schema="bronze")
print(f"Columns: \n{list(df.columns)}")
print(f"\nShape: \n{df.shape}")

Columns: [‘id’, ‘_index’, ‘_ingested_at’, ‘symbol’, ‘long_name’, ‘short_name’, ‘sector’, ‘sector_key’, ‘industry’, ‘industry_key’, ‘country’, ‘city’, ‘website’, ‘long_business_summary’, ‘exchange’, ‘full_exchange_name’, ‘exchange_timezone_name’, ‘exchange_timezone_short’, ‘currency’, ‘financial_currency’, ‘quote_type’, ‘market’, ‘range_start’, ‘price_data_start’]

Shape: (169, 24)

Polars | pl.read_database()

Reads the top-5 rows from bronze.eurostoxx50_ohlcv into a Polars DataFrame using a SQLAlchemy engine — returning natively typed columns including datetime[μs] for _ingested_at and Date for date, unlike Pandas which reads the date column as object.

# Polars with SQLAlchemy engine
df = pl.read_database("SELECT TOP 5 * FROM bronze.eurostoxx50_ohlcv", connection=ENGINE)
display(df)
print(f"Schema: {df.schema}")
id_ingested_atsymboldateopenhighlowcloseadj_closevolumedividendsstock_splits
i64datetime[μs]strdatef64f64f64f64f64i64f64f64
667282026-03-12 12:45:00.017366ASML.AS2026-03-121194.81202.21187.81190.81190.81282230.00.0
667322026-03-12 12:45:00.017366MC.PA2026-03-12495.3497.4491.6494.35494.351719970.00.0
667362026-03-12 12:45:00.017366RMS.PA2026-03-121900.01918.51894.01906.01906.0186810.00.0
667402026-03-12 12:45:00.017366OR.PA2026-03-12361.1362.3357.8360.8360.8826210.00.0
667442026-03-12 12:45:00.017366SAP.DE2026-03-12163.0166.74162.8166.52166.528067220.00.0

Schema: Schema({‘id’: Int64, ‘_ingested_at’: Datetime(time_unit=‘us’, time_zone=None), ‘symbol’: String, ‘date’: Date, ‘open’: Float64, ‘high’: Float64, ‘low’: Float64, ‘close’: Float64, ‘adj_close’: Float64, ‘volume’: Int64, ‘dividends’: Float64, ‘stock_splits’: Float64})

# Filtered query
df = pl.read_database(
    "SELECT [date], symbol, [close], volume FROM bronze.eurostoxx50_ohlcv WHERE symbol = 'ASML.AS' ORDER BY [date] DESC",
    connection=ENGINE
)
display(df.head(10))
print(f"Shape: {df.shape}")
datesymbolclosevolume
datestrf64i64
2026-03-12ASML.AS1190.8128223

Shape: (1, 4)

# Polars with SQLAlchemy engine
df = pl.read_database("SELECT TOP 5 * FROM bronze.index_dim", connection=ENGINE)
display(df)
id_index_ingested_atsymbollong_nameshort_namesectorsector_keyindustryindustry_keycountrycitywebsitelong_business_summaryexchangefull_exchange_nameexchange_timezone_nameexchange_timezone_shortcurrencyfinancial_currencyquote_typemarketrange_startprice_data_start
i64strdatetime[μs]strstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrdatedate
198stoxx_asia_502026-03-04 22:21:55.3849647203.TToyota Motor CorporationTOYOTA MOTOR CORPConsumer Cyclicalconsumer-cyclicalAuto Manufacturersauto-manufacturersJapanToyotahttps://global.toyota/enToyota Motor Corporation desig…JPXTokyoAsia/TokyoJSTJPYJPYEQUITYjp_market1999-05-062021-01-01
199stoxx_asia_502026-03-04 22:21:55.384964BHP.AXBHP Group LimitedBHP GROUP FPO [BHP]Basic Materialsbasic-materialsOther Industrial Metals & Mini…other-industrial-metals-miningAustraliaMelbournehttps://www.bhp.comBHP Group Limited operates as …ASXASXAustralia/SydneyAEDTAUDUSDEQUITYau_market1988-01-282021-01-01
200stoxx_asia_502026-03-04 22:21:55.3849646758.TSony Group CorporationSONY GROUP CORPORATIONTechnologytechnologyConsumer Electronicsconsumer-electronicsJapanTokyohttps://www.sony.comSony Group Corporation designs…JPXTokyoAsia/TokyoJSTJPYJPYEQUITYjp_market2000-01-042021-01-01
201stoxx_asia_502026-03-04 22:21:55.3849641299.HKAIA Group LimitedAIAFinancial Servicesfinancial-servicesInsurance - Lifeinsurance-lifeHong KongCentralhttps://www.aia.comAIA Group Limited, together wi…HKGHKSEAsia/Hong_KongHKTHKDUSDEQUITYhk_market2010-10-292021-01-01
202stoxx_asia_502026-03-04 22:21:55.384964CBA.AXCommonwealth Bank of AustraliaCWLTH BANK FPO [CBA]Financial Servicesfinancial-servicesBanks - Diversifiedbanks-diversifiedAustraliaSydneyhttps://www.commbank.com.auCommonwealth Bank of Australia…ASXASXAustralia/SydneyAEDTAUDAUDEQUITYau_market1991-09-302021-01-01

Chunked Reading (Large Tables)

Chunked Reading

Pandas | pd.read_sql() with chunksize

Iterates through bronze.eurostoxx50_ohlcv in chunks of 10,000 rows using chunksize=10_000, accumulating total row count — demonstrating the iterator pattern for memory-bounded reads of large SQL result sets.

# Read in chunks for memory efficiency
total = 0
for chunk in pd.read_sql("SELECT * FROM bronze.eurostoxx50_ohlcv", ENGINE, chunksize=10_000):
    total += len(chunk)
print(f"Read {total:,} rows in chunks of 10,000")

Read 50 rows in chunks of 10,000

Polars | Batch reading with OFFSET/FETCH

Reads the first 10,000 rows from bronze.eurostoxx50_ohlcv using SQL Server’s OFFSET 0 ROWS FETCH NEXT 10000 ROWS ONLY syntax — showing the SQL pagination pattern used in place of Polars’ absent native batch iteration.

# Polars reads the full result but ConnectorX streams internally
# For very large tables, use a WHERE clause or OFFSET/FETCH
df = pl.read_database(
    "SELECT * FROM bronze.eurostoxx50_ohlcv ORDER BY id OFFSET 0 ROWS FETCH NEXT 10000 ROWS ONLY",
    connection=ENGINE
)
print(f"First batch: {df.shape}")

First batch: (50, 12)

Writing to SQL Server

df.to_sql() is extremely slow by default

df.to_sql() is extremely slow by default — ~100 rows/second Pandas inserts rows one at a time through SQLAlchemy. For bulk loading, use method="multi" (batches inserts) or fast_executemany=True on the engine:

engine = sa.create_engine(url, fast_executemany=True)
df.to_sql("table", engine, if_exists="append", index=False, method="multi")

For tables >100K rows, use bcp instead — it’s 10-50x faster than any ORM approach. See data-transfer for bcp patterns.

Enable fast_executemany or use bcp for bulk loads

Set fast_executemany=True on the SQLAlchemy engine to batch ODBC inserts and reach ~10K rows/second. For datasets >100K rows, export to CSV and bulk-load with bcp or BULK INSERT to reach millions of rows per minute.

if_exists="replace" drops the table

This destroys indexes, constraints, permissions, and foreign keys. Use if_exists="append" with a preceding DELETE for controlled replacement, or use MERGE/upsert patterns from merge-and-upsert.

Use if_exists="append" with a preceding DELETE

Truncate or delete the target rows before appending, preserving the table schema, indexes, and permissions: conn.execute(text("DELETE FROM schema.table")) then df.to_sql("table", engine, if_exists="append", index=False). For partial replacements, use a MERGE statement instead.

Writing DataFrames

Pandas | df.to_sql()

Creates a 2-row test DataFrame with symbol, score, and date columns, writes it to dbo._test_pandas with if_exists="replace", then demonstrates explicit SQL type mapping (NVARCHAR, Float, Date) and row-append behavior.

# Create a test DataFrame
test_df = pd.DataFrame({
    "symbol": ["TEST.XX", "TEST.YY"],
    "score": [0.42, 0.73],
    "date": pd.to_datetime(["2024-01-01", "2024-01-02"]),
})
 
# Write to SQL Server (replace if exists)
test_df.to_sql("_test_pandas", ENGINE, if_exists="replace", index=False)
print("Written to _test_pandas")
 
# Verify
display(pd.read_sql("SELECT * FROM dbo._test_pandas", ENGINE))

Written to _test_pandas

symbolscoredate
0TEST.XX0.422024-01-01
1TEST.YY0.732024-01-02
# Writing options
# if_exists: "fail" (default), "replace" (DROP+CREATE), "append" (INSERT INTO)
# dtype: explicit SQL types
# method: "multi" for faster bulk insert, or callable for custom
 
test_df.to_sql("_test_pandas_typed", ENGINE, if_exists="replace", index=False,
               dtype={  # type: ignore[arg-type]
                   "symbol": sa.types.NVARCHAR(20),
                   "score": sa.types.Float,
                   "date": sa.types.Date,
               })
print("Written with explicit types")
 
# Check the SQL types
with ENGINE.connect() as conn:
    result = conn.execute(sa.text(
        "SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
        "WHERE TABLE_NAME = '_test_pandas_typed' AND TABLE_SCHEMA = 'dbo'"
    ))
    for row in result:
        print(f"  {row[0]:15s}: {row[1]}")

Written with explicit types symbol : nvarchar score : float date : date

# Append rows to existing table
new_rows = pd.DataFrame({
    "symbol": ["TEST.ZZ"],
    "score": [0.55],
    "date": pd.to_datetime(["2024-01-03"]),
})
new_rows.to_sql("_test_pandas", ENGINE, if_exists="append", index=False)
display(pd.read_sql("SELECT * FROM dbo._test_pandas", ENGINE))
symbolscoredate
0TEST.XX0.422024-01-01
1TEST.YY0.732024-01-02
2TEST.ZZ0.552024-01-03
3TEST.ZZ0.552024-01-03

Polars | Write via Pandas bridge and pyodbc fast_executemany

Converts a 2-row Polars DataFrame to Pandas and writes it to dbo._test_polars via SQLAlchemy, then bulk-inserts the same rows directly into _test_bulk using pyodbc’s fast_executemany=True for higher-throughput ODBC batch inserts.

# Polars doesn't have a native write_sql yet — convert to Pandas first
test_pl = pl.DataFrame({
    "symbol": ["PL_TEST.XX", "PL_TEST.YY"],
    "score": [0.88, 0.91],
    "date": ["2024-01-01", "2024-01-02"],
})
 
test_pl.to_pandas().to_sql("_test_polars", ENGINE, if_exists="replace", index=False)
print("Polars → Pandas → SQL Server")
display(pl.read_database("SELECT * FROM dbo._test_polars", connection=ENGINE))

Polars → Pandas → SQL Server

symbolscoredate
strf64str
PL_TEST.XX0.882024-01-01
PL_TEST.YY0.912024-01-02
# For bulk inserts: use pyodbc executemany with fast_executemany
with pyodbc.connect(PYODBC_CONN) as conn:
    conn.autocommit = False
    cursor = conn.cursor()
    cursor.fast_executemany = True
 
    # Create table
    cursor.execute("IF OBJECT_ID('_test_bulk') IS NOT NULL DROP TABLE _test_bulk")
    cursor.execute("CREATE TABLE _test_bulk (symbol NVARCHAR(20), score FLOAT, dt DATE)")
 
    # Bulk insert from Polars
    rows = test_pl.select("symbol", "score", "date").rows()
    cursor.executemany("INSERT INTO _test_bulk (symbol, score, dt) VALUES (?, ?, ?)", rows)
    conn.commit()
 
print(f"Bulk inserted {len(rows)} rows")
display(pl.read_database("SELECT * FROM dbo._test_bulk", connection=ENGINE))

Bulk inserted 2 rows

symbolscoredt
strf64date
PL_TEST.XX0.882024-01-01
PL_TEST.YY0.912024-01-02

Executing SQL Statements

# DDL and DML via SQLAlchemy
with ENGINE.begin() as conn:
    # Create/alter tables
    conn.execute(sa.text(
        "IF OBJECT_ID('_test_exec') IS NOT NULL DROP TABLE _test_exec"
    ))
    conn.execute(sa.text(
        "CREATE TABLE _test_exec (id INT IDENTITY PRIMARY KEY, name NVARCHAR(50), value FLOAT)"
    ))
    # Insert
    conn.execute(sa.text(
        "INSERT INTO _test_exec (name, value) VALUES (:name, :value)"
    ), [{"name": "alpha", "value": 1.1}, {"name": "beta", "value": 2.2}])
 
display(pd.read_sql("SELECT * FROM dbo._test_exec", ENGINE))
idnamevalue
01alpha1.1
12beta2.2
# DDL via pyodbc (for stored procedures, etc.)
with pyodbc.connect(PYODBC_CONN) as conn:
    cursor = conn.cursor()
    cursor.execute("SELECT COUNT(*) as cnt FROM bronze.eurostoxx50_ohlcv")
    total = cursor.fetchone()
    print(f"Total rows: {total[0]:,}")  # type: ignore[index]
 
    # List all user tables
    cursor.execute(
        "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
        "WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"
    )
    tables = [row[0] for row in cursor.fetchall()]
    print(f"Tables ({len(tables)}): {tables[:10]}...")

Total rows: 50 Tables (27): [‘_test_bulk’, ‘_test_exec’, ‘_test_pandas’, ‘_test_pandas_typed’, ‘_test_polars’, ‘dim_country’, ‘dim_index’, ‘eurostoxx50_ohlcv’, ‘eurostoxx50_ohlcv’, ‘index_dim’]…

Stored Procedures

Creating and Executing Stored Procedures

# Call stored procedures and read results
with pyodbc.connect(PYODBC_CONN) as conn:
    cursor = conn.cursor()
 
    # Create a test stored procedure
    cursor.execute("""
        IF OBJECT_ID('sp_test_top_stocks') IS NOT NULL DROP PROCEDURE sp_test_top_stocks
    """)
    cursor.execute("""
        CREATE PROCEDURE sp_test_top_stocks @top_n INT = 5
        AS
        SELECT TOP (@top_n) symbol, AVG([close]) as avg_close
        FROM bronze.eurostoxx50_ohlcv
        GROUP BY symbol
        ORDER BY avg_close DESC
    """)
    conn.commit()
 
# Execute stored procedure via Pandas
df = pd.read_sql("EXEC sp_test_top_stocks @top_n = 10", ENGINE)
display(Markdown("**Top 10 stocks by avg close (stored procedure):**"))
display(df)

Top 10 stocks by avg close (stored procedure)

symbolavg_close
0RMS.PA1906.00
1RHM.DE1551.50
2ASML.AS1190.80
3ADYEN.AS925.70
4ARGX.BR626.60
5MUV2.DE526.20
6MC.PA494.35
7OR.PA360.80
8ALV.DE348.70
9SAF.PA315.40

Schema Inspection

# List all tables with row counts
query = """
SELECT
    t.TABLE_NAME,
    p.rows as row_count
FROM INFORMATION_SCHEMA.TABLES t
JOIN sys.partitions p ON OBJECT_ID(t.TABLE_SCHEMA + '.' + t.TABLE_NAME) = p.object_id
WHERE t.TABLE_TYPE = 'BASE TABLE' AND p.index_id IN (0, 1)
ORDER BY p.rows DESC
"""
display(pd.read_sql(query, ENGINE))
TABLE_NAMErow_count
0eurostoxx50_ohlcv66355
1stoxxusa50_ohlcv65100
2stoxxasia50_ohlcv64045
3trading_calendar29335
4oil20_ohlcv24738
5index_performance5281
6scores_daily466
7signals_daily466
8dim_country212
9signals_quarterly177
10scores_quarterly170
11index_dim169
12index_dim169
13signals_daily169
14signals_quarterly169
15eurostoxx50_ohlcv50
16stoxxusa50_ohlcv50
17stoxxasia50_ohlcv50
18pulse40
19pulse_tickers40
20oil20_ohlcv19
21dim_index4
22_test_pandas4
23_test_exec2
24_test_pandas_typed2
25_test_polars2
26_test_bulk2
# Column details for a specific table
query = """
SELECT
    COLUMN_NAME,
    DATA_TYPE,
    CHARACTER_MAXIMUM_LENGTH,
    IS_NULLABLE,
    COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'eurostoxx50_ohlcv' AND TABLE_SCHEMA = 'bronze'
ORDER BY ORDINAL_POSITION
"""
display(pd.read_sql(query, ENGINE))
COLUMN_NAMEDATA_TYPECHARACTER_MAXIMUM_LENGTHIS_NULLABLECOLUMN_DEFAULT
0idintNaNNONone
1_ingested_atdatetime2NaNNO(sysutcdatetime())
2symbolvarchar20.0NONone
3datedateNaNNONone
4openfloatNaNYESNone
5highfloatNaNYESNone
6lowfloatNaNYESNone
7closefloatNaNYESNone
8adj_closefloatNaNYESNone
9volumebigintNaNYESNone
10dividendsfloatNaNYESNone
11stock_splitsfloatNaNYESNone

Performance: SQLAlchemy vs pyodbc (Pandas vs Polars)

query = "SELECT * FROM bronze.eurostoxx50_ohlcv"
 
# SQLAlchemy (Polars)
t0 = time.perf_counter()
df_pl_sa = pl.read_database(query, connection=ENGINE)
t_pl_sa = time.perf_counter() - t0
 
# SQLAlchemy (Pandas)
t0 = time.perf_counter()
df_sa = pd.read_sql(query, ENGINE)
t_sa = time.perf_counter() - t0
 
# pyodbc via ENGINE.raw_connection()
t0 = time.perf_counter()
 
df_py = pd.read_sql(query, ENGINE)
t_py = time.perf_counter() - t0
 
print(f"SQLAlchemy (Polars): {t_pl_sa:.2f}s — {df_pl_sa.shape}")
print(f"SQLAlchemy (Pandas): {t_sa:.2f}s — {df_sa.shape}")
print(f"pyodbc     (Pandas): {t_py:.2f}s — {df_py.shape}")

SQLAlchemy (Polars): 0.00s — (50, 12) SQLAlchemy (Pandas): 0.00s — (50, 12) pyodbc (Pandas): 0.00s — (50, 12)

Cleanup Test Tables

# Drop test tables created during this notebook
with ENGINE.begin() as conn:
    for table in ["_test_pandas", "_test_pandas_typed", "_test_polars", "_test_bulk", "_test_exec"]:
        conn.execute(sa.text(f"IF OBJECT_ID('{table}') IS NOT NULL DROP TABLE {table}"))
    conn.execute(sa.text("IF OBJECT_ID('sp_test_top_stocks') IS NOT NULL DROP PROCEDURE sp_test_top_stocks"))
print("Test tables and procedures cleaned up")

Test tables and procedures cleaned up

Summary

TaskPandasPolars
Connectionsqlalchemy.create_engine()SQLAlchemy engine
Read tablepd.read_sql(query, engine)pl.read_database(query, uri)
Read with paramspd.read_sql(query, engine, params=[...])Use f-string or ConnectorX params
Chunked readpd.read_sql(query, engine, chunksize=N)Use OFFSET/FETCH in SQL
Write tabledf.to_sql(name, engine)df.to_pandas().to_sql() or pyodbc bulk
Append rowsdf.to_sql(name, engine, if_exists="append")Same via Pandas
Bulk insertto_sql(method="multi")cursor.fast_executemany = True
Execute DDLengine.execute(text(...))cursor.execute(...) via pyodbc
Stored procspd.read_sql("EXEC sp_name", engine)pl.read_database("EXEC sp_name", uri)
SpeedModerateSimilar speed via SQLAlchemy/pyodbc

Common Traps and Safe Patterns

to_sql() defaults to row-by-row inserts

Pandas to_sql() issues inserts conservatively by default. On large DataFrames this becomes a throughput bottleneck very quickly, turning what should be a bulk load into minutes of row-by-row network chatter.

[!success] Batch writes or use bulk loaders

For moderate loads, enable method="multi" or fast_executemany=True. For genuinely large transfers, export and use bcp, BULK INSERT, or another database-native bulk path instead of ORM-style inserts.

String-built SQL is injectable

Queries assembled with f-strings or .format() splice raw values directly into SQL text. As soon as any value is user-controlled, the code becomes vulnerable to SQL injection and loses the plan-caching benefits of parameterization.

[!success] Bind every external value

Use ? parameters with pyodbc and named parameters like :symbol with SQLAlchemy text(). Query text should stay static while values travel separately through the driver parameter channel.

engine.execute() no longer exists

SQLAlchemy 2.x removed the old engine.execute(...) convenience path. Legacy examples that still call it fail with AttributeError, which is easy to misread as a driver or connection problem instead of an API change.

[!success] Execute through a connection

Open an explicit connection or transaction scope and call conn.execute(text(sql), params). That is the current SQLAlchemy execution model and the one new code should standardize on.

read_sql() can exhaust memory

pd.read_sql("SELECT * FROM large_table", engine) materializes the full result set into memory. On wide or high-row-count tables, that can consume all available RAM before any downstream filtering has a chance to reduce the data.

[!success] Limit or stream result sets

Add selective predicates to the query, project only needed columns, and use chunksize= for Pandas or batch iteration patterns for Polars when the full result must be processed. Bring the smallest useful result into memory, not the whole table by default.

Plain-text passwords leak secrets

Hardcoding passwords in connection strings leaves credentials in source control, notebooks, logs, stack traces, and shell history. Once a secret lands in code, it tends to spread far beyond the original file.

[!success] Load credentials from environment or vault

Read database secrets from environment variables, .env files kept out of version control, or a proper secret manager such as Azure Key Vault or GCP Secret Manager. Application code should assemble connection strings at runtime from those injected values.

Python Database and SQL Interface Recommendations

  1. Use DuckDB for complex analytical SQL on DataFrames — CTEs, window functions, and set operations are more readable in SQL than in chained DataFrame expressions.
  2. Use parameterized queries for all database operations — prevents SQL injection and improves query plan caching.
  3. Enable fast_executemany for pyodbc bulk insertscursor.fast_executemany = True dramatically speeds up to_sql() and manual insert loops.
  4. Use connection pooling — SQLAlchemy’s create_engine() pools connections by default. Do not create a new engine per query.
  5. Validate schema after reading from database — column names, types, and nullability may differ from expectations due to database-side changes.
  6. Prefer Parquet over database round-trips for intermediate data — if the data doesn’t need to persist in a database, Parquet reads are 10–50x faster than SQL queries.

Troubleshooting and failure modes

SymptomLikely causeFix
OperationalError: ODBC driver not foundODBC Driver for SQL Server not installedInstall ODBC Driver 18 for SQL Server from Microsoft
InterfaceError: connection refusedWrong server name, port, or firewall ruleVerify server name, port (default 1433), and network access
to_sql() is extremely slowRow-by-row insert modeSet method="multi" or fast_executemany=True
ProgrammingError: table already existsto_sql() with if_exists="fail" (default)Use if_exists="append" or if_exists="replace"
DataError: string or binary data would be truncatedDataFrame string column exceeds the database column’s VARCHAR(N) limitIncrease the column size in the database, or truncate strings before insert
DuckDB query returns wrong typesDuckDB infers types independently from Polars/PandasCast columns explicitly in the SQL query
read_sql() returns empty DataFrameQuery returns no rows, or wrong database/schema targetedRun the query directly in the database client to verify