Query Builder Class — Chainable, Reusable SQL in Python

Query Builder Class — Chainable, Reusable SQL in Python

A query builder wraps SQL construction in a fluent Python API. Each method returns self so calls can be chained. The final .build() returns a safe (sql, params) tuple ready for psycopg2.

from __future__ import annotations
from typing import Any
import psycopg2
from psycopg2.extras import RealDictCursor


class QueryBuilder:
    """Minimal, safe SQL SELECT builder for PostgreSQL."""

    def __init__(self, table: str):
        self._table      = table
        self._selects    = ["*"]
        self._joins      = []
        self._wheres     = []
        self._params     = []
        self._group_bys  = []
        self._havings    = []
        self._havparams  = []
        self._order_bys  = []
        self._limit_val  = None
        self._offset_val = None

    # ── Builder methods ───────────────────────────────────────────
    def select(self, *cols: str) -> QueryBuilder:
        self._selects = list(cols)
        return self

    def join(self, clause: str) -> QueryBuilder:
        self._joins.append(clause)
        return self

    def where(self, clause: str, *params: Any) -> QueryBuilder:
        self._wheres.append(clause)
        self._params.extend(params)
        return self

    def group_by(self, *cols: str) -> QueryBuilder:
        self._group_bys.extend(cols)
        return self

    def having(self, clause: str, *params: Any) -> QueryBuilder:
        self._havings.append(clause)
        self._havparams.extend(params)
        return self

    def order_by(self, col: str, direction: str = "DESC") -> QueryBuilder:
        safe_dir = "DESC" if direction.upper() == "DESC" else "ASC"
        self._order_bys.append(f"{col} {safe_dir}")
        return self

    def limit(self, n: int) -> QueryBuilder:
        self._limit_val = n
        return self

    def offset(self, n: int) -> QueryBuilder:
        self._offset_val = n
        return self

    def build(self) -> tuple[str, list]:
        parts = [f"SELECT {', '.join(self._selects)}",
                 f"FROM {self._table}"]
        parts.extend(self._joins)
        if self._wheres:
            parts.append("WHERE " + " AND ".join(self._wheres))
        if self._group_bys:
            parts.append("GROUP BY " + ", ".join(self._group_bys))
        if self._havings:
            parts.append("HAVING " + " AND ".join(self._havings))
        if self._order_bys:
            parts.append("ORDER BY " + ", ".join(self._order_bys))

        all_params = list(self._params) + list(self._havparams)
        if self._limit_val is not None:
            parts.append("LIMIT %s")
            all_params.append(self._limit_val)
        if self._offset_val is not None:
            parts.append("OFFSET %s")
            all_params.append(self._offset_val)

        return "
".join(parts), all_params

    def execute(self, cur) -> list[dict]:
        sql, params = self.build()
        cur.execute(sql, params)
        return cur.fetchall()

Purchase this course to unlock the full lesson.

Sign up