Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/Added-20260724-120000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Added
body: 'Synchronous Psycopg 3 support: the new `sql_driver` value `psycopg_sync` generates plain synchronous code for `psycopg` with the same supported commands (`:execlastid` stays excluded), models, placeholder rewriting, and type contract as `psycopg_async` - including `:copyfrom` via `cursor.copy()` and the raw-text loader keeping returned `json`/`jsonb` columns `str`. `:many` queries return the same `QueryResults` helper, called instead of awaited. Requires `psycopg >= 3.2`.'
time: 2026-07-24T12:00:00.0000000Z
custom:
Author: Rayakame
PR: "222"
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,29 @@ jobs:
- name: Run sqlc verify via nox
run: |
uv run nox -s psycopg_async_check
psycopg-sync:
runs-on: ubuntu-latest
name: "Run psycopg_sync 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.32"
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_sync_check

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

ci-done:
needs: [ test, upload-coverage, asyncpg, psycopg-async, aiosqlite, sqlite3, pyright, ruff, go-test, go-lint, test-build ]
needs: [test, upload-coverage, asyncpg, psycopg-async, psycopg-sync, aiosqlite, sqlite3, pyright, ruff, go-test, go-lint, test-build]
if: always() && !cancelled()

runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,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/)).
- **Four drivers** - `asyncpg` and `psycopg_async` for PostgreSQL, `aiosqlite`
and `sqlite3` for SQLite
- **Five drivers** - `asyncpg`, `psycopg_async`, and `psycopg_sync` 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
2 changes: 1 addition & 1 deletion docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ layout: hextra-home
subtitle="Generate dataclass, attrs, msgspec, or pydantic models - pick per codegen block."
>}}
{{< hextra/feature-card
title="Four drivers"
title="Five drivers"
link="docs/guide/drivers"
subtitle="asyncpg and psycopg for PostgreSQL, plus aiosqlite and sqlite3 for SQLite."
>}}
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ it.
|---|---|
| **Python** | 3.12 or newer |
| **Engines** | PostgreSQL, SQLite |
| **Drivers** | `asyncpg`, `psycopg_async`, `aiosqlite`, `sqlite3` |
| **Drivers** | `asyncpg`, `psycopg_async`, `psycopg_sync`, `aiosqlite`, `sqlite3` |
| **Model types** | `dataclass`, `attrs`, `msgspec`, `pydantic` |
| **Docstrings** | `google`, `numpy`, `pep257`, or none |
| **Checked with** | pyright (strict) and ruff |
Expand Down
108 changes: 107 additions & 1 deletion docs/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ pip install asyncpg
{{< tab name="psycopg_async" >}}

```bash
pip install "psycopg[binary]"
pip install "psycopg[binary]>=3.2"
```

{{< /tab >}}

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

```bash
pip install "psycopg[binary]>=3.2"
```

{{< /tab >}}
Expand Down Expand Up @@ -121,6 +129,32 @@ sql:

{{< /tab >}}

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

```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_sync"
model_type: "dataclass"
```

{{< /tab >}}

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

```yaml
Expand Down Expand Up @@ -202,6 +236,19 @@ CREATE TABLE users

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

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

{{< /tab >}}

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

```sql
-- filename: schema.sql
CREATE TABLE users
Expand Down Expand Up @@ -269,6 +316,19 @@ SELECT * FROM users ORDER BY name;
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;

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

{{< /tab >}}

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

```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 @@ -366,6 +426,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_sync" >}}

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


# query.py
def get_user(conn: ConnectionLike, *, id_: int) -> models.User | None:
row = 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 @@ -494,6 +578,28 @@ asyncio.run(main())

{{< /tab >}}

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

```python
import psycopg

from app.db import query

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

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

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

{{< /tab >}}

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

```python
Expand Down
8 changes: 4 additions & 4 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`, `psycopg_async`, `aiosqlite`, or `sqlite3` - must match the `engine`. See [Drivers](/docs/guide/drivers). |
| `sql_driver` | `asyncpg`, `psycopg_async`, `psycopg_sync`, `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,9 +88,9 @@ queries - for example a `msgspec` package and a `dataclass` package:

## Common pitfalls

- **Driver/engine mismatch.** `sql_driver: asyncpg` and `psycopg_async` require
`engine: "postgresql"`; `aiosqlite`/`sqlite3` require `engine: "sqlite"`. A
mismatch is an error.
- **Driver/engine mismatch.** `sql_driver: asyncpg`, `psycopg_async`, and
`psycopg_sync` 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.
31 changes: 27 additions & 4 deletions docs/content/docs/guide/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,25 @@ next: /docs/guide/model-types
---

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

| Driver | Engine | Style |
|---|---|---|
| `asyncpg` | `postgresql` | async |
| `psycopg_async` | `postgresql` | async |
| `psycopg_sync` | `postgresql` | sync |
| `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
All 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.
throughput is the priority, and one of the psycopg drivers to stay in the
psycopg ecosystem (libpq, pipeline mode, PgBouncer friendliness) at comparable
speed - `psycopg_async` for asyncio code, `psycopg_sync` for plain synchronous
code.

## asyncpg (PostgreSQL)

Expand Down Expand Up @@ -76,6 +79,26 @@ through `cursor.copy()`.
rejected.
{{< /callout >}}

## psycopg_sync (PostgreSQL)

```python
import psycopg

from app.db import queries

with psycopg.connect("postgresql://user:pass@localhost/db") as conn:
user = queries.get_field_naming(conn, id_=1)
```

The synchronous flavor of the psycopg driver (Psycopg 3.2 or newer, like
`psycopg_async`): identical models, placeholders, and type contract, emitted
as plain functions with no `async`/`await`. The connection annotation is
`psycopg.Connection[psycopg.rows.TupleRow]`, and `:many` queries return the
same `QueryResults` helper - call it (`queries.list_x(conn)()`) to fetch every
row at once, or iterate it directly with a plain `for` loop. The json/jsonb
raw-text loader registration works exactly as on `psycopg_async`; the Windows
event-loop caveat does not apply.

## aiosqlite (async SQLite)

```python
Expand Down
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 PostgreSQL drivers (`asyncpg` and `psycopg_async`).
applies to the PostgreSQL drivers (`asyncpg`, `psycopg_async`, and `psycopg_sync`).
{{< /callout >}}
8 changes: 5 additions & 3 deletions docs/content/docs/guide/writing-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ Variants of `:exec` that return something about the write:
when no row was affected. SQLite drivers only, and note it is the last
*affected* row, not strictly the last inserted one.
- **`:execresult`** - the driver's raw result, which differs per driver: a `str`
status tag on asyncpg, a `psycopg.AsyncCursor` on psycopg, and a
`sqlite3.Cursor` / `aiosqlite.Cursor` on the SQLite drivers.
status tag on asyncpg, a `psycopg.AsyncCursor` / `psycopg.Cursor` on the
psycopg drivers, and a `sqlite3.Cursor` / `aiosqlite.Cursor` on the SQLite
drivers.

See the [feature support matrix](/docs/reference/feature-support) for which
driver supports which.
Expand All @@ -129,7 +130,8 @@ async def test_copy_from(conn: ConnectionLike, *, params: collections.abc.Sequen
return int(n) if (p := r.split()) and (n := p[-1]).isdigit() else 0
```

psycopg streams the rows through `cursor.copy()` instead:
psycopg streams the rows through `cursor.copy()` instead (`psycopg_sync`
emits the same body without `async`/`await`):

```python
async def test_copy_from(conn: ConnectionLike, *, params: collections.abc.Sequence[TestCopyFromParams]) -> int:
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/reference/configuration-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ optional.
| Option | Type | Default | Description |
|---|---|---|---|
| `package` | string | *required* | Name of the generated package. |
| `sql_driver` | string | *required* | One of `asyncpg`, `psycopg_async`, `aiosqlite`, `sqlite3`. Must match the engine (the postgres drivers -> `postgresql`; the sqlite drivers -> `sqlite`). |
| `sql_driver` | string | *required* | One of `asyncpg`, `psycopg_async`, `psycopg_sync`, `aiosqlite`, `sqlite3`. Must match the engine (the postgres drivers -> `postgresql`; the sqlite drivers -> `sqlite`). |
| `emit_init_file` | bool | *required* | Whether to emit an `__init__.py` in the package. Must be set explicitly. Set `false` only if the package already has one. |
| `model_type` | string | `dataclass` | One of `dataclass`, `attrs`, `msgspec`, `pydantic`. See [Model types](/docs/guide/model-types). |
| `initialisms` | list[string] | `["id"]` | Identifier segments to upper-case, e.g. `app_id` -> `AppID`. |
Expand Down
32 changes: 17 additions & 15 deletions docs/content/docs/reference/feature-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ supported (`sqlc.arg`, `sqlc.narg`, `sqlc.embed`, `sqlc.slice`).
The supported [query annotations](https://docs.sqlc.dev/en/latest/reference/query-annotations.html)
depend on the driver:

| Command | aiosqlite | sqlite3 | asyncpg | psycopg_async |
|---|---|---|---|---|
| `:one` | yes | yes | yes | yes |
| `:many` | yes | yes | yes | yes |
| `:exec` | yes | yes | yes | yes |
| `:execresult` | yes | yes | yes | yes |
| `:execrows` | yes | yes | yes | yes |
| `:execlastid` | yes | yes | no | no |
| `:copyfrom` | no | no | yes | yes |
| Command | aiosqlite | sqlite3 | asyncpg | psycopg_async | psycopg_sync |
|---|---|---|---|---|---|
| `:one` | yes | yes | yes | yes | yes |
| `:many` | yes | yes | yes | yes | yes |
| `:exec` | yes | yes | yes | yes | yes |
| `:execresult` | yes | yes | yes | yes | yes |
| `:execrows` | yes | yes | yes | yes | yes |
| `:execlastid` | yes | yes | no | no | no |
| `:copyfrom` | no | no | yes | yes | yes |

See [Writing queries](/docs/guide/writing-queries) for what each command
generates.
Expand Down Expand Up @@ -64,13 +64,14 @@ query gets prepared and which knob controls it:
)
```

- **psycopg** prepares a query server-side once it has been executed more than
`prepare_threshold` times on the connection - with the default of 5, the
sixth execution is the first prepared one. Set it to `0` to prepare from the
first execution, or `None` to never prepare:
- **psycopg** (both flavors) prepares a query server-side once it has been
executed more than `prepare_threshold` times on the connection - with the
default of 5, the sixth execution is the first prepared one. Set it to `0`
to prepare from the first execution, or `None` to never prepare:

```python
conn = await psycopg.AsyncConnection.connect(dsn, prepare_threshold=0)
conn = await psycopg.AsyncConnection.connect(dsn, prepare_threshold=0) # psycopg_async
conn = psycopg.connect(dsn, prepare_threshold=0) # psycopg_sync
```

- **sqlite3 / aiosqlite** expose no explicit prepare API, but the `sqlite3`
Expand All @@ -90,4 +91,5 @@ query gets prepared and which knob controls it:
- **`:batch*` commands** (`:batchexec`, `:batchmany`, `:batchone`) are not
supported and likely never will be.
- **`psycopg2` and `mysql`** drivers are not currently supported; Psycopg 3
is, via the async `psycopg_async` driver.
is, via the `psycopg_async` (asyncio) and `psycopg_sync` (synchronous)
drivers.
Loading
Loading