Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .changes/unreleased/Added-20260722-120000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Added
body: 'Psycopg 3 support: the new `sql_driver` value `psycopg_async` generates asyncio code for `psycopg` with the same commands as asyncpg, including `:copyfrom` via `cursor.copy()`. Queries are rewritten to psycopg''s named pyformat placeholders at generation time and bind their parameters as a dict, the type contract matches asyncpg exactly (returned `json`/`jsonb` columns stay `str` via a registered raw-text loader, so converters behave identically), and the generated code passes pyright strict against psycopg''s `LiteralString` query typing. Requires `psycopg >= 3.2`.'
time: 2026-07-22T12:00:00.0000000Z
custom:
Author: Rayakame
PR: "217"
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ jobs:
- name: Run sqlc verify via nox
run: |
uv run nox -s asyncpg_check
psycopg-async:
runs-on: ubuntu-latest
name: "Run psycopg_async check via nox"
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.31"
python-version: "3.13"

- name: Install sqlc
uses: sqlc-dev/setup-sqlc@bac53b7fb28c039a6c7f5736fd1e89744021bdd6 # v5
with:
sqlc-version: '1.31.1'

- name: Run sqlc verify via nox
run: |
uv run nox -s psycopg_async_check
Comment thread
rayakame marked this conversation as resolved.

aiosqlite:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -292,7 +315,7 @@ jobs:
retention-days: 30

ci-done:
needs: [ test, upload-coverage, asyncpg, aiosqlite, sqlite3, pyright, ruff, go-test, go-lint, test-build ]
needs: [ test, upload-coverage, asyncpg, psycopg-async, aiosqlite, sqlite3, pyright, ruff, go-test, go-lint, test-build ]
Comment thread
rayakame marked this conversation as resolved.
if: always() && !cancelled()

runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ and the full plugin option list in the

- **Four model types** - `dataclass`, `attrs`, `msgspec`, or `pydantic`
([docs](https://rayakame.github.io/sqlc-gen-better-python/docs/guide/model-types/)).
- **Three drivers** - `asyncpg` for PostgreSQL, `aiosqlite` and `sqlite3` for SQLite
- **Four drivers** - `asyncpg` and `psycopg_async` for PostgreSQL, `aiosqlite`
and `sqlite3` for SQLite
([docs](https://rayakame.github.io/sqlc-gen-better-python/docs/guide/drivers/)).
- **Typed query functions** - one module per query file, one function per query
([docs](https://rayakame.github.io/sqlc-gen-better-python/docs/guide/writing-queries/)).
Expand Down
4 changes: 2 additions & 2 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ layout: hextra-home
subtitle="Generate dataclass, attrs, msgspec, or pydantic models - pick per codegen block."
>}}
{{< hextra/feature-card
title="Three drivers"
title="Four drivers"
link="docs/guide/drivers"
subtitle="asyncpg for PostgreSQL, plus aiosqlite and sqlite3 for SQLite."
subtitle="asyncpg and psycopg for PostgreSQL, plus aiosqlite and sqlite3 for SQLite."
>}}
{{< hextra/feature-card
title="Strictly typed output"
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ it.
## Explore by feature

{{< cards >}}
{{< card link="guide/drivers" title="Drivers" icon="database" subtitle="asyncpg, aiosqlite, and sqlite3." >}}
{{< card link="guide/drivers" title="Drivers" icon="database" subtitle="asyncpg, psycopg, aiosqlite, and sqlite3." >}}
{{< card link="guide/model-types" title="Model types" icon="cube" subtitle="dataclass, attrs, msgspec, or pydantic." >}}
{{< card link="guide/writing-queries" title="Writing queries" icon="code" subtitle="How query annotations become typed functions." >}}
{{< card link="guide/enums" title="Enums" icon="collection" subtitle="PostgreSQL enums as enum.StrEnum classes." >}}
Expand All @@ -37,7 +37,7 @@ it.
|---|---|
| **Python** | 3.12 or newer |
| **Engines** | PostgreSQL, SQLite |
| **Drivers** | `asyncpg`, `aiosqlite`, `sqlite3` |
| **Drivers** | `asyncpg`, `psycopg_async`, `aiosqlite`, `sqlite3` |
| **Model types** | `dataclass`, `attrs`, `msgspec`, `pydantic` |
| **Docstrings** | `google`, `numpy`, `pep257`, or none |
| **Checked with** | pyright (strict) and ruff |
Expand Down
115 changes: 115 additions & 0 deletions docs/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ pip install asyncpg

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```bash
pip install "psycopg[binary]"
```

{{< /tab >}}

{{< tab name="aiosqlite" >}}

```bash
Expand Down Expand Up @@ -79,6 +87,32 @@ sql:

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```yaml
# filename: sqlc.yaml
version: "2"
plugins:
- name: python
wasm:
url: https://github.com/rayakame/sqlc-gen-better-python/releases/download/v0.6.0/sqlc-gen-better-python.wasm
sha256: 16f5affb502f2ec65ca61f6fc5ddd993449c4a4fc281996c3c9a9bc2e35b1474
sql:
- engine: "postgresql"
queries: "query.sql"
schema: "schema.sql"
codegen:
- out: "app/db"
plugin: python
options:
package: "db"
emit_init_file: true
sql_driver: "psycopg_async"
model_type: "dataclass"
```

{{< /tab >}}

{{< tab name="aiosqlite" >}}

```yaml
Expand Down Expand Up @@ -147,6 +181,19 @@ sql:

{{< tab name="asyncpg" >}}

```sql
-- filename: schema.sql
CREATE TABLE users
(
id bigint PRIMARY KEY NOT NULL,
name text NOT NULL
);
```

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```sql
-- filename: schema.sql
CREATE TABLE users
Expand Down Expand Up @@ -201,6 +248,19 @@ returns a single row or `None`, `:many` returns all matching rows.
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;

-- name: ListUsers :many
SELECT * FROM users ORDER BY name;
```

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```sql
-- filename: query.sql
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;

-- name: ListUsers :many
SELECT * FROM users ORDER BY name;
```
Expand Down Expand Up @@ -236,6 +296,8 @@ SELECT * FROM users ORDER BY name;
{{< /tabs >}}

PostgreSQL uses `$1` placeholders, SQLite uses `?`. Everything else is the same.
(You write `$1` for psycopg too - the plugin rewrites the placeholders to
psycopg's format at generation time.)

## 4. Generate

Expand Down Expand Up @@ -272,6 +334,30 @@ async def get_user(conn: ConnectionLike, *, id_: int) -> models.User | None:
return models.User(id_=row[0], name=row[1])


def list_users(conn: ConnectionLike) -> QueryResults[models.User]:
...
```

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```python
# models.py
@dataclasses.dataclass()
class User:
id_: int
name: str


# query.py
async def get_user(conn: ConnectionLike, *, id_: int) -> models.User | None:
row = await (await conn.execute(GET_USER, {"p1": id_})).fetchone()
if row is None:
return None
return models.User(id_=row[0], name=row[1])


def list_users(conn: ConnectionLike) -> QueryResults[models.User]:
...
```
Expand Down Expand Up @@ -366,6 +452,35 @@ async def main() -> None:
print(user.name)


asyncio.run(main())
```

{{< /tab >}}

{{< tab name="psycopg_async" >}}

```python
import asyncio

import psycopg

from app.db import query


async def main() -> None:
async with await psycopg.AsyncConnection.connect("postgresql://user:pass@localhost/mydb") as conn:
user = await query.get_user(conn, id_=1)
if user is not None:
print(user.name)

# every row at once
users = await query.list_users(conn)

# or iterate
async for user in query.list_users(conn):
print(user.name)


asyncio.run(main())
```

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guide/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Start with the essentials, then dip into feature pages as you need them:

{{< cards >}}
{{< card link="configuration" title="Configuration" subtitle="The sqlc.yaml plugin block and the core options." >}}
{{< card link="drivers" title="Drivers" subtitle="asyncpg, aiosqlite, and sqlite3 - and which query commands each supports." >}}
{{< card link="drivers" title="Drivers" subtitle="asyncpg, psycopg, aiosqlite, and sqlite3 - and which query commands each supports." >}}
{{< card link="model-types" title="Model types" subtitle="dataclass, attrs, msgspec, or pydantic models." >}}
{{< card link="writing-queries" title="Writing queries" subtitle="How query annotations become typed Python functions." >}}
{{< /cards >}}
Expand Down
7 changes: 4 additions & 3 deletions docs/content/docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ sql:
| Option | What it does |
|---|---|
| `package` | The name of the generated package. |
| `sql_driver` | `asyncpg`, `aiosqlite`, or `sqlite3` - must match the `engine`. See [Drivers](/docs/guide/drivers). |
| `sql_driver` | `asyncpg`, `psycopg_async`, `aiosqlite`, or `sqlite3` - must match the `engine`. See [Drivers](/docs/guide/drivers). |
| `emit_init_file` | Whether to emit `__init__.py`. Must be set explicitly. |

Everything else is optional and has a sensible default. The most common ones to
Expand Down Expand Up @@ -88,8 +88,9 @@ queries - for example a `msgspec` package and a `dataclass` package:

## Common pitfalls

- **Driver/engine mismatch.** `sql_driver: asyncpg` requires `engine: "postgresql"`;
`aiosqlite`/`sqlite3` require `engine: "sqlite"`. A mismatch is an error.
- **Driver/engine mismatch.** `sql_driver: asyncpg` and `psycopg_async` require
`engine: "postgresql"`; `aiosqlite`/`sqlite3` require `engine: "sqlite"`. A
mismatch is an error.
- **Forgetting `emit_init_file`.** It has no default and generation fails if it
is omitted. Set it to `true` unless the package already has an `__init__.py`.
- **A stale `sha256`.** When you bump the plugin version, update the hash too.
46 changes: 42 additions & 4 deletions docs/content/docs/guide/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,23 @@ next: /docs/guide/model-types
---

The `sql_driver` option picks which database library the generated code targets.
It must match your `engine`. Three drivers are supported:
It must match your `engine`. Four drivers are supported:

| Driver | Engine | Style |
|---|---|---|
| `asyncpg` | `postgresql` | async |
| `psycopg_async` | `postgresql` | async |
| `aiosqlite` | `sqlite` | async |
| `sqlite3` | `sqlite` | sync |

Every generated query function takes the connection as its first argument, so you
open and manage the connection yourself and pass it in.

Both PostgreSQL drivers produce the same models and type contract, so choosing
between them is about the driver itself: pick `asyncpg` when raw driver
throughput is the priority, and `psycopg_async` to stay in the psycopg
ecosystem (libpq, pipeline mode, PgBouncer friendliness) at comparable speed.

## asyncpg (PostgreSQL)

```python
Expand All @@ -35,8 +41,40 @@ async def main() -> None:
asyncio.run(main())
```

asyncpg is the only driver that supports `:copyfrom` (bulk insert via
`copy_records_to_table`).
asyncpg supports `:copyfrom` (bulk insert via `copy_records_to_table`).

## psycopg_async (PostgreSQL)

```python
import asyncio

import psycopg

from app.db import queries


async def main() -> None:
conn = await psycopg.AsyncConnection.connect("postgresql://user:pass@localhost/db")
user = await queries.get_field_naming(conn, id_=1)


asyncio.run(main())
```

The generated code targets [Psycopg 3](https://www.psycopg.org/psycopg3/)
(3.2 or newer) with its default tuple rows - the connection annotation is
`psycopg.AsyncConnection[psycopg.rows.TupleRow]`, so a connection configured
with another row factory is rejected by pyright. `:copyfrom` streams rows
through `cursor.copy()`.

{{< callout type="info" >}}
Modules returning `json`/`jsonb` columns register a raw-text loader on
psycopg's process-global adapters map at import time, so those columns stay
`str` exactly like on asyncpg - including for
[converters](/docs/guide/converters). On Windows, psycopg's async support
requires the `SelectorEventLoop`; the default `ProactorEventLoop` is
rejected.
{{< /callout >}}

## aiosqlite (async SQLite)

Expand Down Expand Up @@ -80,5 +118,5 @@ user = queries.get_field_naming(conn, id_=1)
## Command support

Not every [query command](/docs/guide/writing-queries) works on every driver -
for example `:copyfrom` is asyncpg-only and `:execlastid` is SQLite-only. The
for example `:copyfrom` is PostgreSQL-only and `:execlastid` is SQLite-only. The
full matrix is in the [feature support reference](/docs/reference/feature-support).
2 changes: 1 addition & 1 deletion docs/content/docs/guide/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,5 @@ from a `public.mood` that would become `Mood`.

{{< callout type="info" >}}
Enum classes are a PostgreSQL feature - SQLite has no native enum type, so this
applies to the `asyncpg` driver.
applies to the PostgreSQL drivers (`asyncpg` and `psycopg_async`).
{{< /callout >}}
Loading
Loading