PostgreSQL Advanced for ML Tutorial: Analytics and Feature Engineering

# Tutorial 17: PostgreSQL Lanjutan untuk Machine Learning ## Daftar Isi 1. [Pendahuluan](#pendahuluan) 2. [Prasyarat](#prasyarat) 3. [Window Functions untuk Rekayasa Fitur](#window-functions-untuk-r...

By Ruby Abdullah · · tutorial
PostgreSQLSQLFeature EngineeringAnalyticsPythonSQLAlchemy

Tutorial 17: PostgreSQL Advanced for Machine Learning

Table of Contents

  • Introduction
  • Prerequisites
  • Window Functions for Feature Engineering
  • CTEs for Complex Queries
  • Materialized Views for Feature Caching
  • JSONB for ML Metadata
  • Table Partitioning for Large Datasets
  • Monitoring with pgstat
  • Integration with Python
  • Best Practices
  • Conclusion
  • Introduction

    PostgreSQL is far more than a simple relational database. With its advanced analytical functions, flexible data types, and extensibility, it serves as a powerful backbone for machine learning workflows. Many ML engineers underestimate what can be done directly in the database layer, often pulling raw data into Python for transformations that PostgreSQL handles more efficiently.

    This tutorial covers advanced PostgreSQL techniques that are directly applicable to ML pipelines: feature engineering with window functions, query organization with CTEs, caching computed features with materialized views, storing experiment metadata with JSONB, handling large datasets with partitioning, monitoring query performance, and integrating everything with Python using psycopg2 and SQLAlchemy.

    Prerequisites

    • PostgreSQL 14+ installed and running
    • Python 3.9+ with pip
    • Basic SQL knowledge (SELECT, JOIN, GROUP BY)
    • Familiarity with ML concepts (features, training data, experiments)
    • Install required Python packages:

    # Install dependencies
    

    pip install psycopg2-binary sqlalchemy pandas

    import psycopg2

    import sqlalchemy

    import pandas as pd

    Window Functions for Feature Engineering

    Window functions are one of the most powerful tools for ML feature engineering. They allow you to compute aggregates across rows related to the current row without collapsing the result set — perfect for creating rolling statistics, lag features, and ranking features.

    Rolling Statistics

    -- Create a sample transactions table
    

    CREATE TABLE transactions (

    id SERIAL PRIMARY KEY,

    customerid INTEGER NOT NULL,

    amount DECIMAL(10, 2) NOT NULL,

    category VARCHAR(50),

    createdat TIMESTAMP NOT NULL DEFAULT NOW()

    );

    -- Rolling average transaction amount (last 7 days) per customer

    SELECT

    id,

    customerid,

    amount,

    createdat,

    AVG(amount) OVER (

    PARTITION BY customerid

    ORDER BY createdat

    RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW

    ) AS rollingavg7d,

    STDDEV(amount) OVER (

    PARTITION BY customerid

    ORDER BY createdat

    RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW

    ) AS rollingstddev7d,

    COUNT() OVER (

    PARTITION BY customerid

    ORDER BY createdat

    RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW

    ) AS txncount7d

    FROM transactions

    ORDER BY customerid, createdat;

    Lag and Lead Features

    Lag features are essential for time-series ML models. They capture temporal patterns without data leakage when used correctly.

    -- Lag features: previous transaction amounts and time deltas
    

    SELECT

    id,

    customerid,

    amount,

    createdat,

    LAG(amount, 1) OVER w AS prevamount1,

    LAG(amount, 2) OVER w AS prevamount2,

    LAG(amount, 3) OVER w AS prevamount3,

    amount - LAG(amount, 1) OVER w AS amountdiff,

    EXTRACT(EPOCH FROM (

    createdat - LAG(createdat, 1) OVER w

    )) / 3600.0 AS hourssincelasttxn,

    NTILE(10) OVER (

    PARTITION BY customerid

    ORDER BY amount

    ) AS amountdecile

    FROM transactions

    WINDOW w AS (PARTITION BY customerid ORDER BY createdat);

    Rank-Based Features

    -- Rank customers by spending within each category
    

    SELECT

    customerid,

    category,

    SUM(amount) AS totalspent,

    RANK() OVER (

    PARTITION BY category

    ORDER BY SUM(amount) DESC

    ) AS spendingrank,

    PERCENTRANK() OVER (

    PARTITION BY category

    ORDER BY SUM(amount) DESC

    ) AS spendingpercentile

    FROM transactions

    GROUP BY customerid, category;

    CTEs for Complex Queries

    Common Table Expressions (CTEs) let you break complex feature engineering queries into readable, modular steps. They are essential when your feature pipeline involves multiple transformations.

    Multi-Step Feature Pipeline

    -- Complete feature engineering pipeline using CTEs
    

    WITH customerstats AS (

    -- Step 1: Basic aggregations per customer

    SELECT

    customerid,

    COUNT() AS totaltransactions,

    SUM(amount) AS totalspent,

    AVG(amount) AS avgamount,

    STDDEV(amount) AS stddevamount,

    MIN(amount) AS minamount,

    MAX(amount) AS maxamount,

    MIN(createdat) AS firsttransaction,

    MAX(createdat) AS lasttransaction

    FROM transactions

    GROUP BY customerid

    ),

    categoryfeatures AS (

    -- Step 2: Spending distribution across categories

    SELECT

    customerid,

    COUNT(DISTINCT category) AS uniquecategories,

    MODE() WITHIN GROUP (ORDER BY category) AS mostcommoncategory,

    MAX(CASE WHEN category = 'electronics' THEN amount ELSE 0 END) AS maxelectronics

    FROM transactions

    GROUP BY customerid

    ),

    recencyfeatures AS (

    -- Step 3: RFM-style recency features

    SELECT

    customerid,

    EXTRACT(EPOCH FROM (NOW() - MAX(createdat))) / 86400.0 AS dayssincelasttxn,

    EXTRACT(EPOCH FROM (MAX(createdat) - MIN(createdat))) / 86400.0 AS customertenuredays

    FROM transactions

    GROUP BY customerid

    )

    -- Step 4: Combine all features

    SELECT

    cs.customerid,

    cs.totaltransactions,

    cs.totalspent,

    cs.avgamount,

    cs.stddevamount,

    cs.maxamount - cs.minamount AS amountrange,

    COALESCE(cs.stddevamount / NULLIF(cs.avgamount, 0), 0) AS cvamount,

    cf.uniquecategories,

    cf.mostcommoncategory,

    rf.dayssincelasttxn,

    rf.customertenuredays,

    cs.totaltransactions / NULLIF(rf.customertenuredays, 0) AS txnfrequency

    FROM customerstats cs

    JOIN categoryfeatures cf ON cs.customerid = cf.customerid

    JOIN recencyfeatures rf ON cs.customerid = rf.customerid;

    Recursive CTEs for Graph Features

    -- Find referral chain depth (graph-like feature)
    

    WITH RECURSIVE referralchain AS (

    -- Base case: direct referrals

    SELECT

    customerid,

    referredby,

    1 AS chaindepth

    FROM customers

    WHERE referredby IS NOT NULL

    UNION ALL

    -- Recursive step

    SELECT

    rc.customerid,

    c.referredby,

    rc.chaindepth + 1

    FROM referralchain rc

    JOIN customers c ON rc.referredby = c.customerid

    WHERE c.referredby IS NOT NULL

    AND rc.chaindepth < 10 -- Safety limit

    )

    SELECT customerid, MAX(chaindepth) AS referraldepth

    FROM referralchain

    GROUP BY customerid;

    Materialized Views for Feature Caching

    Materialized views store the result of a query physically on disk, making them ideal for caching expensive feature computations that do not need real-time freshness.

    -- Create a materialized view for customer features
    

    CREATE MATERIALIZED VIEW mvcustomerfeatures AS

    SELECT

    t.customerid,

    COUNT() AS totaltransactions,

    SUM(t.amount) AS totalspent,

    AVG(t.amount) AS avgtransaction,

    STDDEV(t.amount) AS stddevtransaction,

    COUNT(DISTINCT t.category) AS categorydiversity,

    COUNT(DISTINCT DATETRUNC('day', t.createdat)) AS activedays,

    MAX(t.createdat) AS lastactivity,

    EXTRACT(EPOCH FROM (NOW() - MAX(t.createdat))) / 86400.0 AS daysinactive

    FROM transactions t

    GROUP BY t.customerid

    WITH DATA;

    -- Create an index for fast lookups

    CREATE UNIQUE INDEX idxmvcustomerfeaturesid

    ON mvcustomerfeatures (customerid);

    -- Refresh the view (run on a schedule, e.g., daily via cron)

    REFRESH MATERIALIZED VIEW CONCURRENTLY mvcustomerfeatures;

    Refresh Strategy

    -- Track when views were last refreshed
    

    CREATE TABLE mvrefreshlog (

    viewname TEXT PRIMARY KEY,

    lastrefreshed TIMESTAMP NOT NULL DEFAULT NOW(),

    durationms INTEGER,

    rowcount BIGINT

    );

    -- Refresh function with logging

    CREATE OR REPLACE FUNCTION refreshfeatureview(viewname TEXT)

    RETURNS VOID AS $

    DECLARE

    starttime TIMESTAMP;

    endtime TIMESTAMP;

    rcount BIGINT;

    BEGIN

    starttime := clocktimestamp();

    EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I', viewname);

    endtime := clocktimestamp();

    EXECUTE format('SELECT COUNT() FROM %I', viewname) INTO rcount;

    INSERT INTO mvrefreshlog (viewname, lastrefreshed, durationms, rowcount)

    VALUES (

    viewname,

    NOW(),

    EXTRACT(MILLISECONDS FROM (endtime - starttime))::INTEGER,

    rcount

    )

    ON CONFLICT (viewname)

    DO UPDATE SET

    lastrefreshed = EXCLUDED.lastrefreshed,

    durationms = EXCLUDED.durationms,

    rowcount = EXCLUDED.rowcount;

    END;

    $ LANGUAGE plpgsql;

    -- Usage

    SELECT refreshfeatureview('mvcustomerfeatures');

    JSONB for ML Metadata

    PostgreSQL's JSONB type is perfect for storing semi-structured ML metadata such as hyperparameters, evaluation metrics, and model configurations.

    -- Experiment tracking table
    

    CREATE TABLE mlexperiments (

    id SERIAL PRIMARY KEY,

    experimentname VARCHAR(200) NOT NULL,

    modeltype VARCHAR(100) NOT NULL,

    hyperparameters JSONB NOT NULL DEFAULT '{}',

    metrics JSONB NOT NULL DEFAULT '{}',

    featureconfig JSONB,

    tags TEXT[],

    createdat TIMESTAMP NOT NULL DEFAULT NOW(),

    trainingdurationseconds FLOAT

    );

    -- Insert an experiment

    INSERT INTO mlexperiments (experimentname, modeltype, hyperparameters, metrics, tags)

    VALUES (

    'churnpredictionv3',

    'xgboost',

    '{"nestimators": 500, "maxdepth": 6, "learningrate": 0.01,

    "subsample": 0.8, "colsamplebytree": 0.8}',

    '{"accuracy": 0.923, "precision": 0.891, "recall": 0.876,

    "f1": 0.883, "aucroc": 0.954}',

    ARRAY['production', 'churn', 'v3']

    );

    -- Query experiments by hyperparameter values

    SELECT

    experimentname,

    hyperparameters->>'learningrate' AS lr,

    metrics->>'aucroc' AS auc,

    metrics->>'f1' AS f1

    FROM mlexperiments

    WHERE modeltype = 'xgboost'

    AND (hyperparameters->>'maxdepth')::int >= 5

    AND (metrics->>'aucroc')::float > 0.90

    ORDER BY (metrics->>'aucroc')::float DESC;

    -- Aggregate metrics across experiments

    SELECT

    modeltype,

    AVG((metrics->>'aucroc')::float) AS avgauc,

    MAX((metrics->>'aucroc')::float) AS bestauc,

    COUNT() AS numexperiments

    FROM mlexperiments

    GROUP BY modeltype

    ORDER BY avgauc DESC;

    -- GIN index for fast JSONB queries

    CREATE INDEX idxexperimentshyperparams ON mlexperiments

    USING GIN (hyperparameters);

    CREATE INDEX idxexperimentsmetrics ON mlexperiments

    USING GIN (metrics);

    Table Partitioning for Large Datasets

    When your training data grows to hundreds of millions of rows, partitioning becomes essential for query performance and data management.

    -- Create a partitioned table for ML training data
    

    CREATE TABLE mltrainingdata (

    id BIGSERIAL,

    featurevector FLOAT8[] NOT NULL,

    label INTEGER NOT NULL,

    datasplit VARCHAR(10) NOT NULL CHECK (datasplit IN ('train', 'val', 'test')),

    createddate DATE NOT NULL DEFAULT CURRENTDATE,

    source VARCHAR(50),

    PRIMARY KEY (id, createddate)

    ) PARTITION BY RANGE (createddate);

    -- Create monthly partitions

    CREATE TABLE mltrainingdata202601

    PARTITION OF mltrainingdata

    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

    CREATE TABLE mltrainingdata202602

    PARTITION OF mltrainingdata

    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

    CREATE TABLE mltrainingdata202603

    PARTITION OF mltrainingdata

    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

    -- Automate partition creation

    CREATE OR REPLACE FUNCTION createmonthlypartition(

    tablename TEXT,

    year INT,

    month INT

    ) RETURNS VOID AS $

    DECLARE

    partitionname TEXT;

    startdate DATE;

    enddate DATE;

    BEGIN

    partitionname := format('%s%s%s', tablename, year,

    LPAD(month::TEXT, 2, '0'));

    startdate := makedate(year, month, 1);

    enddate := startdate + INTERVAL '1 month';

    EXECUTE format(

    'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I

    FOR VALUES FROM (%L) TO (%L)',

    partitionname, tablename, startdate, enddate

    );

    RAISE NOTICE 'Created partition: %', partitionname;

    END;

    $ LANGUAGE plpgsql;

    -- Create partitions for the next 12 months

    DO $

    BEGIN

    FOR m IN 1..12 LOOP

    PERFORM createmonthlypartition('mltrainingdata', 2026, m);

    END LOOP;

    END $;

    Partition Pruning in Queries

    -- PostgreSQL automatically prunes irrelevant partitions
    

    EXPLAIN ANALYZE

    SELECT COUNT(), AVG(label::float)

    FROM mltrainingdata

    WHERE createddate BETWEEN '2026-01-01' AND '2026-01-31'

    AND datasplit = 'train';

    -- Only scans mltrainingdata202601

    Monitoring with pgstat

    Understanding query performance is critical when your ML pipeline depends on database queries. PostgreSQL provides extensive statistics through pgstat views.

    -- Enable pgstatstatements (add to postgresql.conf)
    

    -- sharedpreloadlibraries = 'pgstatstatements'

    -- Top slow queries hitting your ML tables

    SELECT

    calls,

    meanexectime::numeric(10,2) AS avgms,

    totalexectime::numeric(10,2) AS totalms,

    rows,

    LEFT(query, 120) AS querypreview

    FROM pgstatstatements

    WHERE query ILIKE '%transaction%' OR query ILIKE '%ml%'

    ORDER BY meanexectime DESC

    LIMIT 20;

    -- Table-level statistics

    SELECT

    schemaname,

    relname AS tablename,

    seqscan,

    seqtupread,

    idxscan,

    idxtupfetch,

    ntupins AS inserts,

    ntupupd AS updates,

    ntupdel AS deletes,

    nlivetup AS liverows,

    ndeadtup AS deadrows,

    lastvacuum,

    lastautovacuum,

    lastanalyze

    FROM pgstatusertables

    WHERE relname LIKE 'ml%' OR relname LIKE 'mv%'

    ORDER BY seqtupread DESC;

    -- Index usage analysis

    SELECT

    indexrelname AS indexname,

    relname AS tablename,

    idxscan AS timesused,

    pgsizepretty(pgrelationsize(indexrelid)) AS indexsize

    FROM pgstatuserindexes

    WHERE schemaname = 'public'

    ORDER BY idxscan DESC;

    -- Cache hit ratio (should be > 99%)

    SELECT

    sum(heapblksread) AS heapread,

    sum(heapblkshit) AS heaphit,

    ROUND(sum(heapblkshit) 100.0 /

    NULLIF(sum(heapblkshit) + sum(heapblksread), 0), 2

    ) AS cachehitratio

    FROM pgstatiousertables;

    Integration with Python

    Using psycopg2 for Direct Access

    import psycopg2
    

    from psycopg2.extras import RealDictCursor, executevalues

    import numpy as np

    import pandas as pd

    class MLDatabaseClient:

    """Database client optimized for ML workflows."""

    def init(self, host, port, dbname, user, password):

    self.connparams = {

    "host": host,

    "port": port,

    "dbname": dbname,

    "user": user,

    "password": password,

    }

    self.conn = None

    def connect(self):

    self.conn = psycopg2.connect(self.connparams)

    self.conn.autocommit = False

    return self

    def close(self):

    if self.conn:

    self.conn.close()

    def enter(self):

    return self.connect()

    def exit(self, exctype, excval, exctb):

    if exctype:

    self.conn.rollback()

    else:

    self.conn.commit()

    self.close()

    def fetchfeatures(self, customerids: list) -> pd.DataFrame:

    """Fetch pre-computed features for a list of customers."""

    query = """

    SELECT FROM mvcustomerfeatures

    WHERE customerid = ANY(%s)

    """

    with self.conn.cursor(cursorfactory=RealDictCursor) as cur:

    cur.execute(query, (customerids,))

    rows = cur.fetchall()

    return pd.DataFrame(rows)

    def fetchtrainingdata(self, split: str, datefrom: str,

    dateto: str, limit: int = None) -> pd.DataFrame:

    """Fetch training data with partition pruning."""

    query = """

    SELECT featurevector, label

    FROM mltrainingdata

    WHERE datasplit = %s

    AND createddate BETWEEN %s AND %s

    """

    params = [split, datefrom, dateto]

    if limit:

    query += " LIMIT %s"

    params.append(limit)

    with self.conn.cursor() as cur:

    cur.execute(query, params)

    rows = cur.fetchall()

    features = np.array([row[0] for row in rows])

    labels = np.array([row[1] for row in rows])

    return features, labels

    def logexperiment(self, name: str, modeltype: str,

    hyperparams: dict, metrics: dict, tags: list):

    """Log an ML experiment to the database."""

    import json

    query = """

    INSERT INTO mlexperiments

    (experimentname, modeltype, hyperparameters, metrics, tags)

    VALUES (%s, %s, %s, %s, %s)

    RETURNING id

    """

    with self.conn.cursor() as cur:

    cur.execute(query, (

    name, modeltype,

    json.dumps(hyperparams),

    json.dumps(metrics),

    tags

    ))

    experimentid = cur.fetchone()[0]

    self.conn.commit()

    return experimentid

    def bulkinserttrainingdata(self, features: np.ndarray,

    labels: np.ndarray, split: str):

    """Efficiently bulk insert training data."""

    data = [

    (features[i].tolist(), int(labels[i]), split)

    for i in range(len(labels))

    ]

    query = """

    INSERT INTO mltrainingdata (featurevector, label, datasplit)

    VALUES %s

    """

    with self.conn.cursor() as cur:

    executevalues(cur, query, data, pagesize=1000)

    self.conn.commit()

    Usage example

    if name == "main":

    with MLDatabaseClient(

    host="localhost", port=5432,

    dbname="mldb", user="mluser", password="secret"

    ) as db:

    # Fetch features

    df = db.fetchfeatures([1, 2, 3, 4, 5])

    print(f"Fetched {len(df)} customer feature rows")

    # Log experiment

    expid = db.logexperiment(

    name="churnv4",

    modeltype="lightgbm",

    hyperparams={"nestimators": 1000, "learningrate": 0.05},

    metrics={"aucroc": 0.961, "f1": 0.892},

    tags=["production", "churn"]

    )

    print(f"Logged experiment ID: {expid}")

    Using SQLAlchemy ORM

    from sqlalchemy import (
    

    createengine, Column, Integer, String, Float,

    DateTime, ARRAY, JSON, func, text

    )

    from sqlalchemy.orm import declarativebase, Session

    from sqlalchemy.dialects.postgresql import JSONB

    from datetime import datetime

    Base = declarativebase()

    class MLExperiment(Base):

    tablename = "mlexperiments"

    id = Column(Integer, primarykey=True, autoincrement=True)

    experimentname = Column(String(200), nullable=False)

    modeltype = Column(String(100), nullable=False)

    hyperparameters = Column(JSONB, default={})

    metrics = Column(JSONB, default={})

    tags = Column(ARRAY(String))

    createdat = Column(DateTime, default=datetime.utcnow)

    trainingdurationseconds = Column(Float)

    def repr(self):

    return f"name} ({self.modeltype})>"

    engine = createengine(

    "postgresql://mluser:secret@localhost:5432/mldb",

    poolsize=10,

    maxoverflow=20,

    echo=False

    )

    Query best experiments using SQLAlchemy

    with Session(engine) as session:

    bestexperiments = (

    session.query(MLExperiment)

    .filter(MLExperiment.modeltype == "xgboost")

    .filter(

    MLExperiment.metrics["aucroc"].astext.cast(Float) > 0.90

    )

    .orderby(

    MLExperiment.metrics["aucroc"].astext.cast(Float).desc()

    )

    .limit(10)

    .all()

    )

    for exp in bestexperiments:

    print(f"{exp.experimentname}: AUC={exp.metrics['aucroc']}")

    # Use raw SQL for complex feature queries

    featuredf = pd.readsql(

    text("SELECT * FROM mvcustomerfeatures WHERE daysinactive < 30"),

    engine

    )

    Best Practices

  • Compute features in SQL when possible. Window functions and CTEs execute close to the data, avoiding costly data transfers to Python for transformations that SQL handles natively.
  • Use materialized views for stable features. Refresh them on a schedule (e.g., nightly) rather than recomputing on every training run.
  • Always index JSONB columns with GIN indexes if you query by specific keys. Use jsonbpathops for containment queries.
  • Partition large tables by date. This gives you free partition pruning on time-range queries and makes it easy to drop old data.
  • Monitor your queries. Enable pgstatstatements and review slow queries weekly. A single missing index can turn a 50ms query into a 50-second one.
  • Use connection pooling (PgBouncer or built-in SQLAlchemy pooling) in production ML services to avoid connection overhead.
  • Set appropriate workmem for ML queries. Feature engineering queries with large sorts and window functions benefit from higher workmem (e.g., 256MB per session during batch processing).
  • Use COPY for bulk loading instead of INSERT for large datasets. The psycopg2.copyexpert method is significantly faster.
  • Conclusion

    PostgreSQL is a powerful ally in any ML pipeline. By leveraging window functions for feature engineering, CTEs for readable query pipelines, materialized views for caching, JSONB for flexible metadata, and partitioning for scale, you can handle a significant portion of your ML data processing directly in the database. Combined with Python integration through psycopg2 and SQLAlchemy, PostgreSQL becomes a production-grade foundation for end-to-end machine learning workflows. The monitoring capabilities through pg_stat views ensure you can keep your data pipeline performant as your datasets grow.

    Related Articles

    Ibis Tutorial: The Portable Python DataFrame API Across Backends

    Ibis: API Dataframe Python yang Portabel di Banyak Backend Ibis adalah library dataframe Python yang memungkinkan Anda m...

    DuckDB: In-Process Analytical Database for Data Science

    DuckDB: Database Analitik In-Process untuk Data Science DuckDB adalah database analitik in-process yang dirancang khusus...

    SQLModel: Modern Python ORM for Type-Safe AI Applications

    SQLModel: ORM Modern Python untuk Aplikasi AI yang Type-Safe Dalam pengembangan aplikasi AI/ML, pengelolaan data di data...

    Feature Engineering Masterclass Tutorial: Feature Techniques for ML

    Tutorial 14: Masterclass Rekayasa Fitur (Feature Engineering) Daftar Isi Pendahuluan Prasyarat Mengapa Rekayasa Fitur Pe...