martinjuul / duckdb
Native DuckDB driver for PHP (C extension). Synchronous, streaming and asynchronous queries, prepared statements, transactions, bulk appender, typed errors.
Package info
github.com/martin-juul/php-duckdb
Language:C++
Type:php-ext
Ext name:ext-duckdb
pkg:composer/martinjuul/duckdb
Requires
- php: >= 8.2
Requires (Dev)
None
Suggests
- ext-swoole: >=6.0: PendingQuery::suspend() yields Swoole coroutines natively instead of blocking the scheduler
- ext-uv: For event-loop integration of async queries via PendingQuery::getFd()
- amphp/amp: ^3.0: PendingQuery::suspend() suspends fibers on the Revolt event loop instead of blocking
- react/async: ^4.0: PendingQuery::suspend() awaits on the ReactPHP event loop instead of blocking
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 1.4.1
- 1.4.0
- 1.3.1
- 1.3.0
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.0
- 1.0.0
- dev-feature/custom-logging
- dev-feature/public-expression-apis
- dev-feature/table-function-metadata
- dev-feature/scalar-bind-init-callbacks
- dev-feature/custom-copy-functions
- dev-feature/selection-vectors
- dev-feature/standalone-vectors
- dev-feature/arrow-c-data-and-packaging
- dev-fix/duckdb-sdk-cache
- dev-feature/value-string-rendering
- dev-feature/typed-value-classes
- dev-feature/windows-packaging
- dev-fix/pending-query-cleanup
- dev-fix/fiber-test-scheduling
- dev-feature/duckdb-1.5.6
- dev-release/1.2.1
This package is auto-updated.
Last update: 2026-10-21 19:09:38 UTC
README
duckdb is a PHP extension that puts a complete analytical SQL database inside your PHP process. It embeds DuckDB — an in-process, columnar OLAP engine — and exposes it as an idiomatic, fully typed PHP API.
What it's for: analytical workloads from PHP: aggregations and joins over millions of rows, direct queries on CSV/Parquet/JSON files, ETL pipelines, reporting and data-quality checks. These run without provisioning, connecting to or operating a database server. Applications that currently shell out to a CLI, export data to another system or load whole tables into arrays can perform that work in the PHP process.
What it is not for: high-concurrency OLTP, such as thousands of small writes from many web requests. Use MySQL/PostgreSQL for that workload. DuckDB permits writes from one process at a time and is designed for analytical scans and bulk work.
Feature highlights:
- Synchronous queries with buffered or streaming results
- Prepared statements with positional (
?,$1) and named ($name,:name) parameters - Transactions with PDO-style helpers
- Bulk inserts via the Appender API
- Arrow batches through the C Data Interface, with native chunk conversion, schema inspection and batch appending
- Standalone vectors: typed native columns built from PHP and assembled into chunks for appending or Arrow export
- Selection vectors: pick, reorder and repeat vector and chunk rows natively, for example to filter a batch before appending it
- COPY TO formats in PHP: stream query results to a
file format implemented by a PHP class, with
COPY ... TO ... (FORMAT name) - Asynchronous execution on background worker threads, with cancellation, progress reporting, Fiber suspension, coroutine-native Swoole 6+, True Async, AMPHP v3 and ReactPHP integration, and event-loop support (completion stream / file descriptor)
- Single-threaded async via DuckDB's pending-execution API (no worker thread started by the extension — drives the query on the calling thread)
- Typed error hierarchy mirroring DuckDB's error categories
- Typed inputs for every DuckDB 1.5.6 type family, including nested values, catalog types, VARIANT and GEOMETRY. Results use documented PHP mappings, including exact DECIMAL/HUGEINT strings and temporal objects; those mappings have type-specific precision limitations.
The runnable examples and API map cover the public API
and application workflows: order analytics, typed ingestion, Arrow batch
exchange and asynchronous reporting. Run them with php tests/harness.php examples.
This development checkout also exposes UTF-8 checks
through DuckDB\isValidUtf8() and DuckDB\checkUtf8(), without opening a database.
Comparison with alternatives
| Approach | Trade-offs |
|---|---|
| PDO + ODBC | Requires an ODBC driver and driver manager. Type conversion depends on the driver and PHP interface. |
| DuckDB CLI | Useful for scripts and batch jobs. The application manages subprocesses, input/output and error reporting. |
| FFI binding to libduckdb | Calls the C API without compiling a dedicated PHP extension. The binding must manage native memory and ownership, and PHP must have FFI enabled. |
| SQLite / MySQL / PostgreSQL | Different database engines, rather than alternative DuckDB bindings. Choose according to workload and operational requirements. |
| This driver | Requires a compiled extension matching the PHP build. Provides prepared statements, typed binding, streaming, async execution and DuckDB error categories through native PHP classes. |
How it works
PHP script
│ DuckDB\{Database, Connection, Statement, Result, PendingQuery, Appender}
▼
duckdb extension (C++ / Zend API) ← this project
│ thin, ownership-explicit wrapper
▼
libduckdb (stable C API)
│ vectorized execution, columnar chunks
▼
your data (memory / .duckdb file / Parquet / CSV / …)
- In-process: queries execute on threads inside the PHP process — no server,
socket, or network hop. A
Databaseis a file handle (or pure memory); aConnectionis a session on it. - Columnar → PHP: results arrive as chunks of typed column vectors; the extension decodes them into PHP values row by row as you fetch. Buffered queries materialize up front; streaming queries keep memory flat no matter how large the result.
- Explicit ownership: every DuckDB handle has exactly one owner in the
extension (RAII). Destruction order is always safe: a
Resultmay outlive theConnectionit came from without retaining a dangling handle. - Error reporting: invalidated streams, mid-stream query errors, NUL bytes in SQL and pathological nesting all raise exceptions instead of silently truncating data or crashing the process (see Safety guarantees).
For application integration, see the PHP developer guide. Check the compatibility matrix and migration guide before upgrading.
Requirements
- PHP 8.2+ (8.2–8.5 supported; enums are used in the public API)
- A C++17 compiler
- The DuckDB C library (
libduckdb+duckdb.h), e.g. from https://duckdb.org/docs/installation/ — this driver is developed and tested against DuckDB v1.5.6, the minimum supported version; headers and library must match and provide the required C APIs.
Repository builds that vendor DuckDB use the patched SDK builder. Its engine patch fixes nullable bitpacking writes while retaining compression. External DuckDB packages need an equivalent backport; see compatibility. The patch inventory records its purpose, origin, build scope and removal criteria.
/opt/duckdb
├── include/duckdb.h
└── lib/libduckdb.(so|dylib|lib)
Installing with PIE
On Linux and macOS, install PIE and the
requirements above, plus the target PHP version's development tools (phpize
and php-config), make, and autoconf. Install the DuckDB C library
separately; PIE builds this extension against the supplied prefix:
pie install martinjuul/duckdb --with-duckdb=/opt/duckdb
php -r '$c = (new DuckDB\Database())->connect(); var_dump($c->query("SELECT 42 AS x")->fetchRow());'
On macOS, use --with-duckdb=$(brew --prefix duckdb). PIE uses the PHP
installation that runs it and attempts to enable the extension automatically.
If that step fails, follow its instructions to add extension=duckdb.so to the
same PHP installation's configuration. Windows PIE binary distribution is not
provided yet. On Windows, use the matching x64 PHP 8.2–8.5 TS/NTS ZIP from a
release that includes Windows assets; see the
Windows installation guide.
Building from source
php tests/harness.php doctor build --duckdb-dir=/opt/duckdb make install # copies duckdb.so into the PHP extension dir echo "extension=duckdb.so" > $(php --ini | grep 'Scan for' | awk '{print $NF}')/99-duckdb.ini php -r 'var_dump(DuckDB\version());' # e.g. "v1.5.6"
On macOS use --duckdb-dir=$(brew --prefix duckdb). On Windows use
config.w32 with phpize from the PHP SDK.
CLion and CMake setup
Opening this repository in CLion with its default Ninja profile works without
setting DUCKDB_DIR when a repository phpize/harness build already selected an
SDK in config.nice. CMake reads that file as configuration data and checks the
SDK's required APIs. A missing legacy cached /opt/duckdb default is migrated
when the IDE reloads the project. If an automatically discovered temporary SDK
disappears, the next configure retries discovery and automatic setup;
DUCKDB_AUTO_BUILD=OFF still prevents downloading or compilation.
On a fresh checkout, CMake automatically runs the shared SDK builder into
<build directory>/duckdb-sdk. Cold configuration downloads the pinned engine,
applies all repository patches and compiles it with the resource-aware worker
budget. The builder needs Python 3, CMake, C/C++ compilers, curl, tar, patch and
make; DUCKDB_BUILD_JOBS and DUCKDB_SDK_CACHE_DIR retain their usual meanings.
The first configure can therefore take substantially longer than later reloads.
Reloads verify automatically built SDK artifacts and source/patch pins; a pin
change can trigger a rebuild. Explicit or config.nice SDKs remain managed by
the build that supplied them.
For an existing SDK, pass -DDUCKDB_DIR=/path/to/sdk in the CLion profile or
set DUCKDB_DIR in its environment. Explicit paths are checked directly, with
no fallback to an arbitrary system engine. For offline configuration, use
-DDUCKDB_AUTO_BUILD=OFF and provide or reuse an existing SDK. SDK downloading
and compilation then remain disabled.
cmake -S . -B cmake-build-debug -G Ninja
cmake --build cmake-build-debug --parallel 2
CMake is an auxiliary Unix build for IDEs and analysis. Use the harness for
normal validation and config.w32 for Windows PHP SDK builds.
For Packagist registration and tagged PIE releases, see the publishing instructions.
Run the test suite:
LD_LIBRARY_PATH=/opt/duckdb/lib \ php tests/harness.php --full --duckdb-dir=/opt/duckdb
Docker images
Multi-arch images (linux/amd64 + linux/arm64) with the extension
preinstalled are published to the GitHub Container Registry for every supported
PHP version, built from the Dockerfile in this repository:
docker run --rm ghcr.io/martin-juul/php-duckdb:8.4 \
-r '$c = (new DuckDB\Database())->connect(); var_dump($c->query("SELECT 42 AS x")->fetchRow());'
Choose how closely to pin the extension and PHP versions:
| Tag examples | Selection |
|---|---|
8.4, php8.4 |
Moving build for PHP 8.4; also available for PHP 8.2, 8.3 and 8.5 |
latest |
Moving build for the newest supported PHP version (currently 8.5) |
1.4.1-php8.4 |
Extension release 1.4.1 with PHP 8.4 |
1.4-php8.4, 1-php8.4 |
Moving extension minor/major release aliases with PHP 8.4 |
1.4.1 |
Extension release 1.4.1 with the newest supported PHP version |
1.4, 1 |
Moving extension minor/major release aliases with the newest supported PHP version |
PHP-only aliases update on master pushes and stable release builds. Extension
release aliases are published on Git tags such as v1.4.1 or 1.4.1;
major/minor aliases follow the most recently published matching release build.
Prereleases such as 1.4.1-rc.1 publish only their full release tags, with and
without the PHP suffix, and leave stable aliases unchanged. Pull requests build
images without publishing tags.
CI builds and smoke-tests each architecture on a native runner. Publishing jobs combine the successful AMD64 and ARM64 builds into the multi-architecture tags.
Images are based on php:X.Y-cli-bookworm and ship the matching libduckdb, so
the extension loads with no extra setup. To build locally instead:
docker build --build-arg PHP_VERSION=8.4 -t php-duckdb:8.4 .
ZTS variants for FrankenPHP are published as
8.4-frankenphp and 8.5-frankenphp (plus latest-frankenphp), based on
dunglas/frankenphp with the extension compiled against the embedded ZTS PHP —
ready for classic and worker mode out of the box:
docker run --rm --entrypoint php \
ghcr.io/martin-juul/php-duckdb:8.5-frankenphp \
-r 'var_dump(DuckDB\version());'
Every tag form above also has a -frankenphp variant for PHP 8.4 and 8.5, such
as php8.4-frankenphp, 1.4.1-php8.4-frankenphp, 1.4-php8.4-frankenphp,
1.4.1-frankenphp and 1-frankenphp. Release-only FrankenPHP tags use PHP 8.5.
They are built from Dockerfile.frankenphp in this repository; see
docs/frankenphp.md for worker-mode details.
Quick start
<?php use DuckDB\Database; $db = new Database(':memory:'); // or new Database('/data/shop.duckdb') $conn = $db->connect(); $conn->query('CREATE TABLE ducks (id INTEGER, name VARCHAR, hatched DATE)'); $conn->execute('INSERT INTO ducks VALUES (?, ?, ?)', [1, 'Quackers', '2024-05-01']); $result = $conn->query('SELECT * FROM ducks'); foreach ($result as $row) { // Result is IteratorAggregate echo $row['name'], "\n"; }
Database owns the database file handle; Connection is a logical session. A
single database supports many connections — use one connection per concurrent
unit of work (see Concurrency below).
Executing queries
| Method | Returns | Semantics |
|---|---|---|
Connection::query(string $sql): Result |
buffered | Runs the SQL (multiple statements allowed; the last statement's result is returned). |
Connection::queryStreaming(string $sql): Result |
streaming | Constant-memory, chunk-at-a-time fetching. |
Connection::execute(string $sql, array $params = []): Result |
buffered | Prepare + bind + execute in one call. |
Connection::prepare(string $sql): Statement |
— | Reusable prepared statement. |
Connection::queryAsync(string $sql): PendingQuery |
— | Executes on a background worker thread. |
Connection::queryPending(string $sql): PendingQuery |
— | Single-threaded async (polling mode). |
Results
$result = $conn->query('SELECT id, name FROM ducks'); $row = $result->fetchRow(); // assoc array, null at end $row = $result->fetchRow(DuckDB\FetchMode::Num); // positional array $rows = $result->fetchAll(); // all remaining rows $val = $result->fetchColumn(); // first column of next row $result->rowCount(); // buffered results only $result->rowsChanged(); // rows written by INSERT/UPDATE/DELETE $result->statementType(); // 'SELECT', 'INSERT', 'CREATE', ... $result->columns(); // [['name' => 'id', 'type' => 'INTEGER'], ...] $result->columnCount(); $result->columnName(0); $result->columnType(0);
Results are forward-only. A Result provides exactly one iterator, and
iterators cannot be rewound. This follows the underlying streaming protocol
and prevents the same result from being consumed twice.
Streaming & the one-stream rule
queryStreaming() / Statement::executeStreaming() keep memory constant for
arbitrarily large results. DuckDB allows one open streaming result per
connection: starting any new query (or appender) on the same connection
invalidates the previous stream. This driver detects that situation and throws a
DuckDB\Exception instead of silently truncating your data — buffered query()
results are not affected. For concurrent streams, open one connection per
stream.
A query that fails mid-stream, for example on a cast error millions of rows into a result, throws the usual typed exception while fetching. A stream never silently truncates. Fetching again rethrows the error; the connection stays usable.
Arrow batches
These APIs are available starting with extension 1.4.0.
Fetch columnar batches from buffered or streaming results, inspect their schemas, and convert them to reusable native chunks. Lossless Arrow conversion is enabled by default. Explicitly disabling it makes exports of HUGEINT, UHUGEINT, BIT and TIMETZ throw instead of losing data. The bundled SDK preserves geometry CRS without requiring an explicit transaction. VARIANT export is unsupported. See conversion semantics.
$source->query('SET arrow_lossless_conversion = true'); $result = $source->queryStreaming('SELECT i::INTEGER AS id FROM range(10000) t(i)'); $schema = $result->arrowSchema()->toArray(); while (($arrow = $result->fetchArrowChunk()) !== null) { $chunk = $destination->dataChunkFromArrow($arrow); $appender->appendChunk($chunk); }
Create the destination table and appender on a separate connection before the
loop. Conversion consumes the Arrow batch; native chunks can be appended,
decoded with toRows() and exported again. Native address import/export uses
the Arrow C Data Interface. Schema exports copy, array exports move, and
callers own release of native exports. These APIs work without PHP FFI;
FFI is optional for native address exchange. See Arrow conversion
for ownership, mixed row fetching and supported conversion semantics, and the
runnable example.
Vectors
These APIs are available starting with extension 1.4.0.
Build typed native columns from PHP values and assemble them into chunks. Writes convert input like typed binding, and a rejected batch leaves the vector unchanged.
$ids = $conn->createVector('BIGINT', 3); $tags = $conn->createVector('VARCHAR[]', 3); $ids->setValues($conn, [1, 2, 3]); $tags->setValues($conn, [['new'], [], null]); $appender->appendChunk(DuckDB\DataChunk::fromVectors(['id' => $ids, 'tags' => $tags], 3));
A chunk holds at most DuckDB\vectorSize() rows and copies its vectors. See
vectors and the runnable example.
Selections copy chosen rows of a vector or chunk without decoding them. Pass a
reusable DuckDB\SelectionVector or a plain list of row indices:
$chunk = $conn->dataChunkFromArrow($result->fetchArrowChunk()); $valid = array_keys(array_filter($chunk->vector(1)->toArray(), fn($v) => $v !== null)); $appender->appendChunk($chunk->select($valid));
See selection vectors and the runnable example.
COPY TO formats
A PHP class can implement a COPY ... TO output format. DuckDB streams each
result batch to the writer that open() returns:
final class JsonLines implements DuckDB\CopyToFunction { public function bind(array $columnTypes, array $options): void {} public function open(string $path, array $columnTypes, array $options): DuckDB\CopyToWriter { return new class($path) implements DuckDB\CopyToWriter { private $out; public function __construct(string $path) { $this->out = fopen($path, 'w'); } public function write(DuckDB\DataChunk $batch): void { foreach ($batch->toRows() as $row) { fwrite($this->out, json_encode($row) . "\n"); } } public function close(): void { fclose($this->out); } public function abort(Throwable $reason): void { fclose($this->out); } }; } } $conn->registerCopyToFunction('jsonl_php', new JsonLines()); $conn->query("COPY (SELECT * FROM events) TO 'events.jsonl' (FORMAT jsonl_php)");
The format is available only on the registering connection, and its handlers run on the request thread. See COPY TO formats and the runnable example.
PHP scalar functions
Scalar bind/init callbacks are under development after extension 1.4.1.
Connection::registerScalarFunction() registers a PHP ScalarFunction with
explicit parameter and return types. Its bind() returns a ScalarBinding,
whose init() creates a ScalarExecutor for each executing DuckDB worker.
The executor receives a DataChunk and returns one output value per row.
Callbacks run on the request thread, must not suspend, and receive NULL
arguments. Functions are volatile and available only on the registering
connection. Use synchronous, streaming or pending queries; worker-based
queryAsync() and executeAsync() reject PHP scalar execution. See
scalar functions and the
runnable example.
Prepared statements
$stmt = $conn->prepare('INSERT INTO ducks VALUES (?, ?, ?)'); foreach ($rows as [$id, $name, $hatched]) { $stmt->execute([$id, $name, $hatched]); } // Named parameters ($name / :name), with or without the prefix: $stmt = $conn->prepare('SELECT * FROM ducks WHERE name = $name'); $stmt->bindValue('name', 'Quackers'); $row = $stmt->execute()->fetchRow(); // Metadata: $stmt->parameterCount(); // number of parameters $stmt->parameterName(1); // 'name', or '1' for positional $stmt->parameterType(1); // 'VARCHAR' — resolved from context when possible $stmt->statementType(); // 'SELECT', 'INSERT', ... $stmt->columnCount(); // result columns without executing $stmt->columnName(0); $stmt->columnType(0); $stmt->clearBindings(); // reset all bound values
Parameter types come from the SQL context. In INSERT INTO t VALUES (?), the
table schema supplies the type; in WHERE i = ?::INTEGER, the cast supplies it.
An unconstrained parameter (SELECT ?) cannot be typed until a value is bound,
which is also how the DuckDB C API behaves.
PHP → DuckDB binding types
| PHP value | DuckDB type |
|---|---|
null |
NULL |
bool |
BOOLEAN |
int |
BIGINT |
float |
DOUBLE |
string |
VARCHAR (binary-safe) |
string via Statement::bindBlob() |
BLOB |
DateTimeInterface |
TIMESTAMP (microsecond precision, UTC) |
DuckDB\Interval |
INTERVAL |
list<mixed> |
LIST (element types must be uniform; null elements adapt) |
array<string, mixed> |
STRUCT |
For an explicit type, use the native typed input classes:
$stmt->bindValue('amount', new DuckDB\Decimal('12.345', precision: 18, scale: 2)); $conn->execute('SELECT ?', [new DuckDB\ListValue([], DuckDB\Integer::class)]);
Wrappers also work in Appender and nested inputs. DuckDB resolves and casts them on the consuming connection at execution time. See the complete typed input contract and roadmap.
Bound values never enter the SQL text, so binding is always safe against SQL injection. Always prefer parameters over string interpolation.
Transactions
$conn->beginTransaction(); try { $conn->execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); $conn->execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); $conn->commit(); } catch (DuckDB\Exception $e) { $conn->rollBack(); throw $e; }
DuckDB uses optimistic concurrency: a conflicting concurrent write fails one of
the transactions with a TransactionException during a write or commit. Roll
back the failed transaction and retry the unit of work. Transaction state is
owned by DuckDB. Nesting beginTransaction() or committing without an active
transaction raises a TransactionException (code
ErrorType::Transaction). Plain SQL (BEGIN/COMMIT/ROLLBACK) works
identically.
Bulk inserts: the Appender
The appender loads rows without executing a prepared INSERT for each row. For bulk loads, benchmark it against prepared statements with your data:
$appender = $conn->appender('ducks'); // (table, schema?, catalog?) foreach ($rows as [$id, $name, $hatched]) { $appender->appendRow([$id, $name, $hatched]); } $appender->flush(); // optional; happens on close/destruct $appender->close(); // idempotent
To use column defaults, build each row one value at a time:
$appender->beginRow(); $appender->append(42); // next column value $appender->appendDefault(); // use the column's DEFAULT $appender->append('Quackers'); $appender->endRow();
A native submission or flush failure blocks further appends until clear()
discards all buffered rows and any partial row. It cannot undo rows already
flushed; use a transaction when the entire batch must roll back. Conversion
errors before submission leave the appender usable. See
Appender recovery.
Async execution
$pending = $conn->queryAsync('SELECT count(*) FROM huge_table'); $pending->isReady(); // non-blocking completion check $result = $pending->await(); // block this thread until done $pending->cancel(); // interrupt the query (-> InterruptedException)
queryAsync()/Statement::executeAsync()run the query on a worker thread. Worker threads never touch PHP state; completion is signalled through a socket pair (loopback TCP on Windows):PendingQuery::getStream()returns a PHP stream usable withstream_select(),getFd()the raw descriptor (a duplicate — closing it is safe) for compatible event loops. On Windows, raw handles are Winsock sockets requiringclosesocket(); prefer PHP streams.PendingQuery::suspend()waits for completion without blocking the scheduler: natively integrated with Swoole 6+, True Async, AMPHP v3 and ReactPHP (below), with a plain Fiber protocol as the fallback for custom schedulers.Connection::queryPending()runs on the calling thread. EachisReady()call executes one slice of DuckDB's task graph without spawning a worker thread. Polling performs the work rather than busy-waiting. This mode suitsdl()-restricted and other specialized SAPIs.- A
PendingQueryresult can be consumed exactly once (await()orsuspend()).
suspend() integrates with the async runtimes below. When several are
installed,
the first available runtime handles the call in this order: Swoole 6+ → True
Async → AMPHP → ReactPHP → plain fiber protocol. Frameworks usually ship
alone,
so this overlap is uncommon.
Swoole 6+
When ext-swoole 6.0+ is loaded, calling PendingQuery::suspend() inside a
Swoole coroutine yields that coroutine on the query's completion descriptor.
Swoole's scheduler resumes it when DuckDB's worker thread finishes. The event
loop can therefore serve other coroutines while the query runs. Detection is
automatic and requires no configuration, build flags or link-time dependency on
Swoole. OpenSwoole uses different namespaces and constants, so it is not
engaged;
suspend() there falls back to the fiber contract.
use function Swoole\Coroutine\run; run(function () use ($db) { $cids = []; foreach ($queries as $label => $sql) { $cids[] = Swoole\Coroutine::create(function () use ($db, $sql, $label, &$results) { // one connection per coroutine: connections serialize their queries $results[$label] = $db->connect()->queryAsync($sql)->suspend()->fetchAll(); }); } Swoole\Coroutine::join($cids); // queries ran concurrently });
Rules of thumb under Swoole:
- Use
queryAsync()+suspend()(orStatement::executeAsync()) for anything non-trivial. The synchronousquery()executes on the calling thread and blocks the whole scheduler while it runs. Millisecond queries may be acceptable; heavy scans keep other coroutines waiting. - Polling mode (
queryPending()) also works inside coroutines:suspend()drives the query in slices and yields between them. cancel()from a peer coroutine works and surfaces asDuckDB\InterruptedExceptionin the suspended one.- One connection per coroutine. A
Connectionserializes its queries by design; connections to the sameDatabaseare cheap.
See examples/swoole.php for a complete runnable version.
True Async
True Async is the async php-src fork with
Async\spawn() / Async\await() and structured concurrency backed by libuv.
Under a True Async PHP binary, calling PendingQuery::suspend() inside an
Async\ coroutine waits in the libuv reactor on the query's completion
descriptor in worker mode. In polling mode, it yields via Async\delay()
between
DuckDB task slices. The scheduler thread stays free for other coroutines while
the query runs. Detection is automatic and requires no configuration or build
flags.
$coroutines = []; foreach ($queries as $label => $sql) { $coroutines[$label] = Async\spawn(function () use ($db, $sql) { // one connection per coroutine: connections serialize their queries return $db->connect()->queryAsync($sql)->suspend()->fetchAll(); }); } $results = Async\await_all_or_fail($coroutines); // queries ran concurrently
Rules of thumb under True Async (same as under Swoole):
- Use
queryAsync()+suspend()for anything non-trivial; the synchronousquery()blocks the calling thread while it runs. - Cancelling a coroutine (
$coroutine->cancel()) is cooperative and propagates intosuspend(): the in-flight DuckDB query is interrupted and\Cancellation(e.g.Async\AsyncCancellation) escapes the suspending call. - One connection per coroutine — DuckDB interrupts are connection-level, so a cancelled query would take down sibling queries sharing its connection.
- Once the runtime is engaged, the fork treats the root context as a coroutine
too, so top-level
suspend()works as well.
True Async is a full php-src fork rather than a loadable extension: build this
driver with the fork's phpize and run it with the fork's php binary (see
examples/true_async.php, and tests/050_true_async.phpt which runs when the
test suite itself runs under that binary).
AMPHP v3
AMPHP v3 runs on the Revolt event loop in userland, so it
needs no linked library or additional extension. When Revolt\EventLoop is
loadable, PendingQuery::suspend() suspends the current fiber on the loop. In
worker modes, a loop watcher fires when the query's completion stream becomes
readable. In polling mode, the query runs in slices with Amp\delay()-style
yields between them. Other fibers keep running while the query executes.
Detection is automatic.
$futures = []; foreach ($queries as $label => $sql) { $futures[$label] = Amp\async(function () use ($db, $sql) { // one connection per fiber: connections serialize their queries return $db->connect()->queryAsync($sql)->suspend()->fetchAll(); }); } $results = Amp\Future\await($futures); // queries ran concurrently
Rules of thumb under AMPHP (same spirit as under Swoole):
- Use
queryAsync()+suspend()for anything non-trivial; the synchronousquery()blocks the calling thread while it runs. - Query errors surface inside the awaiting fiber as ordinary
DuckDB\Exceptionsubclasses. - At the top level of a script,
suspend()works too: the main context waits by running the event loop, exactly likeFuture::await(). - One connection per fiber — a
Connectionserializes its queries by design; connections to the sameDatabaseare cheap.
AMPHP v3 requires PHP 8.1+; install it with composer require amphp/amp. See
examples/amphp.php for a complete runnable version, and tests/051_amphp.phpt
(activated via DUCKDB_AMPHP_AUTOLOAD or a project vendor/autoload.php).
ReactPHP (react/async v4+)
ReactPHP is likewise userland: with react/async v4
or later loadable, PendingQuery::suspend() awaits a promise on the
React\EventLoop that resolves when the query's completion stream becomes
readable (worker modes), or yields via React\Async\delay() between DuckDB task
slices (polling mode). The loop keeps serving other events while the query runs.
Detection is automatic; react/async v3 is deliberately not engaged (its
fiber model differs — suspend() there falls back to the plain fiber contract).
use function React\Async\{async, await}; $promises = []; foreach ($queries as $label => $sql) { $promises[$label] = async(function () use ($db, $sql) { // one connection per fiber: connections serialize their queries return $db->connect()->queryAsync($sql)->suspend()->fetchAll(); })(); } $results = array_map(await(...), $promises); // queries ran concurrently
Rules of thumb under ReactPHP:
- Use
queryAsync()+suspend()for anything non-trivial; the synchronousquery()blocks the calling thread while it runs. - Cancelling the surrounding
async()promise is cooperative: the awaited promise rejects, the in-flight DuckDB query is interrupted, and aRuntimeExceptionescapessuspend(). - At the top level of a script,
suspend()works too: react/async's scheduler fiber runs the loop while the main context waits. - One connection per fiber — a
Connectionserializes its queries by design; connections to the sameDatabaseare cheap.
Install with composer require react/async:^4.0. See examples/reactphp.php
for a complete runnable version, and tests/052_reactphp.phpt (activated via
DUCKDB_REACTPHP_AUTOLOAD or a project vendor/autoload.php).
Progress & interruption
$conn->interrupt(); // cancel ALL running queries on this connection $progress = $conn->queryProgress(); // ['percentage' => 41.5, 'rowsProcessed' => 415000, 'totalRowsToProcess' => 1000000]
queryProgress() is thread-safe and may be polled from another fiber/thread
while a query runs (percentage is -1.0 when no estimate is available).
Error handling
Every DuckDB failure throws a subclass of DuckDB\Exception chosen by DuckDB's
machine-readable error category. getCode() returns the DuckDB\ErrorType
backing value; getErrorType() returns the enum case.
use DuckDB\{Exception, CatalogException, ConstraintException, ErrorType}; try { $conn->query('SELECT * FROM missing'); } catch (CatalogException $e) { // table/view/schema problems } catch (ConstraintException $e) { // PK/UNIQUE/FK/NOT NULL/CHECK violations } catch (Exception $e) { $e->getErrorType(); // ?DuckDB\ErrorType ErrorType::tryFrom($e->getCode()); }
| Exception | Typical causes |
|---|---|
ConnectionException |
connect failures, using a closed connection |
ParserException |
SQL syntax errors |
BinderException |
unknown identifiers, type resolution, unknown parameter names |
CatalogException |
missing tables/schemas/columns |
ConstraintException |
constraint violations |
ConversionException |
failed casts, out-of-range, division by zero |
TransactionException |
transaction conflicts, invalid transaction state |
IOException |
file system, network, HTTP, extension loading — including Database open failures with DuckDB's IO Error: prefix (unwritable path, single-writer lock conflict) |
InterruptedException |
interrupt() / PendingQuery::cancel() |
InternalException |
DuckDB-internal errors (please report upstream) |
Programmer errors raise native PHP errors instead: ValueError for invalid
arguments (bad parameter index, wrong value count, non-uniform lists), Error
for protocol violations (e.g. Appender::append() without beginRow()).
Type mapping (DuckDB → PHP)
| DuckDB | PHP |
|---|---|
BOOLEAN |
bool |
TINYINT–BIGINT, UTINYINT–UBIGINT |
int when it fits, else exact decimal string |
HUGEINT, UHUGEINT |
int when it fits, else exact decimal string |
BIGNUM |
exact decimal string |
FLOAT, DOUBLE |
float |
DECIMAL |
exact decimal string (no precision loss) |
VARCHAR, ENUM |
string |
BLOB, GEOMETRY |
string (binary) |
BIT |
string (text of 0 and 1 digits) |
UUID |
string (canonical) |
DATE |
DateTimeImmutable (midnight UTC) |
TIMESTAMP, TIMESTAMP_S/MS/NS/TZ |
DateTimeImmutable (UTC) |
TIME, TIME_TZ, TIME_NS |
string |
INTERVAL |
DuckDB\Interval (getMonths()/getDays()/getMicros(), JsonSerializable) |
LIST, ARRAY |
list |
STRUCT |
array<string, mixed> |
MAP |
assoc array (list of ['key'=>…,'value'=>…] for non-scalar keys) |
UNION |
the member value |
VARIANT |
string (JSON rendering) |
NULL |
null |
Non-finite temporal values (infinity) are returned as strings. Integers that
exceed PHP's integer range and exact decimals are returned as strings so no
value ever loses precision silently.
Configuration
$db = new Database('/data/shop.duckdb', [ 'access_mode' => 'read_only', 'threads' => 4, 'memory_limit' => '1GB', ]);
Any
DuckDB configuration option
is accepted (string|int|float|bool values). Unknown options fail with
DuckDB\Exception code ErrorType::InvalidConfiguration (42).
Connection lifecycle
$conn->close(); // idempotent; further use throws ConnectionException $conn->isClosed(); // bool
Internal reference counts keep resources alive while they are in use. A
Database may be freed while its connections remain alive. A Connection may
be freed or closed while its async queries finish safely in the background. In
long-running processes, close each connection when you are done with it.
Other introspection:
$conn->getTableNames('SELECT * FROM orders JOIN customers USING (customer_id)'); // ['orders', 'customers'] — query analysis, not a catalog listing DuckDB\version(); // 'v1.5.6' (linked library version) duckdb_version(); // legacy alias
Safety guarantees
The test suite covers the following input checks and resource-lifecycle rules:
- No silent SQL truncation: SQL text or identifiers containing NUL bytes are
rejected with
ValueError(the DuckDB C API is NUL-terminated, so an embedded NUL would otherwise truncate the statement mid-flight). - No silent data truncation: invalidated streams (one-stream rule) and mid-stream query failures both throw; you never get a partial result disguised as a complete one.
- Bounded nesting: ordinary PHP↔DuckDB value conversion has a depth limit
of 512. Typed declarations, snapshots and conversion use a limit of 64.
Excessive nesting raises
ValueError. - Object lifecycle: native resource classes are
final, uncloneable and rejectserialize()/unserialize(). Typed wrappers also reject cloning and serialization.DuckDB\Valuepermits custom subclasses; the dedicated type classes arefinal.DuckDB\Intervalis a separate value class. - Closed-resource discipline: using a closed connection (queries, binding,
streaming, appending) throws
ConnectionException; closing twice is a no-op. - Memory checks: the full harness runs Valgrind over the PHPT suite, apart
from tests explicitly marked
--VALGRIND-SKIP--. Validation results and their scope are recorded in typed input coverage.
Concurrency & thread-safety model
- Each
Connectionserializes its own DuckDB calls with an internal mutex — you cannot corrupt state by mixing sync/async use of one connection, but queries on one connection never run in parallel. For parallel queries, open one connection per query ($db->connect()is cheap). - File-backed databases support one writer process. A second process opening
the same file for writing fails with
IOException(ErrorType::Io, "Could not set lock on file ..."). Multiple processes may open the database read-only when no process has it open for writing. Within one process,Databaseobjects opened with the same resolved file path share the native database handle. Destroying one wrapper does not close the handle while another wrapper or connection still owns it. In-memory databases remain private to eachDatabaseobject. - Worker threads only execute DuckDB calls; all PHP/zval access happens on the
request thread. Safe under ZTS,
parallel, FrankenPHP, etc. - PHP callbacks such as COPY TO format handlers also run only
on the request thread: DuckDB workers hand their calls to it while it drives
the statement. Handlers must not suspend or use the connection running the
statement, and
queryAsync()/executeAsync()reject statements that need them. - DuckDB interrupt is connection-scoped:
PendingQuery::cancel()on a threaded query interrupts the whole connection (cancelling a finished query is a no-op and never disturbs newer work).
FrankenPHP
The extension is ZTS-safe and works under FrankenPHP
in both classic and worker mode — verified against FrankenPHP's embedded ZTS PHP
in CI, including a concurrent worker-mode smoke test and a graceful-shutdown
check. Because FrankenPHP embeds a ZTS PHP, the extension must be built
against the same PHP version with ZTS enabled. The quickest start is the
prebuilt image (ghcr.io/martin-juul/php-duckdb:8.5-frankenphp, see
Docker images); to build your own, the
dunglas/frankenphp:builder-* images contain everything needed. See
docs/frankenphp.md for the build recipe and worker-mode
semantics (persistent :memory: databases per worker thread, async queries in
workers, safe shutdown), and examples/frankenphp.php
for a runnable worker script.
Development
Test harness
tests/harness.php is the single entry point for every verification stage —
never run run-tests.php or Valgrind by hand:
php tests/harness.php # doctor + build + unit + examples php tests/harness.php --full # ... plus valgrind + stress php tests/harness.php --quick # doctor + unit only php tests/harness.php unit --filter='03*' # just the type tests php tests/harness.php valgrind # Memcheck over the .phpt suite php tests/harness.php --full --junit=report.xml
Stages:
- doctor verifies the toolchain (PHP ≥ 8.2, phpize, libduckdb, run-tests.php, valgrind) with actionable errors.
- build runs phpize/configure/make, incrementally;
--rebuildforces a clean build. - unit runs the
.phptsuite in parallel (--jobs=N,--filter=PATTERN). - examples runs every
examples/*.php; all must exit 0. - valgrind re-runs the suite under Memcheck with Zend's allocator disabled;
definite leaks or memory errors fail the stage. glibc/DuckDB thread-lifecycle
noise is suppressed via
tests/duckdb.supp; tests too slow for Memcheck carry a--VALGRIND-SKIP--marker. - stress runs
tests/stress/*.php: memory-stability and concurrency/cancellation loops.
On x86_64, Valgrind 3.27.1 can crash in its own stack unwinder while testing PHP Fibers. The crash is in Valgrind, before Memcheck reports a client error. Use the pinned builder to apply the stack-read guard and keep the full leak checks enabled:
packaging/valgrind/build.sh valgrind_prefix="${XDG_CACHE_HOME:-$HOME/.cache}/php-duckdb/valgrind-3.27.1-stack-unwind" PATH="$valgrind_prefix/bin:$PATH" php tests/harness.php --full
Exit codes: 0 all green, 1 a stage failed, 2 environment problem, 3
build failure. --junit=FILE emits a JUnit report for CI.
Layout
duckdb.stub.php defines every signature and supplies the IDE/static-analysis
stubs. Generate duckdb_arginfo.h using the generator from PHP 8.2 source,
the minimum supported PHP version, and commit the generated file:
php /path/to/php-8.2-src/build/gen_stub.php --force-regeneration duckdb.stub.php
Never edit duckdb_arginfo.h by hand; regenerate it in the same commit as the
stub change. Newer PHP generators can emit macros unavailable on PHP 8.2,
even when the stub signatures themselves are compatible.
Layout:
duckdb.cpp Database, Connection, module lifecycle
src/statement.cpp Statement (prepare/bind/metadata/execute)
src/result.cpp Result, ResultIterator, type decoding
src/pending.cpp PendingQuery, worker threads, polling mode, suspend() dispatch
src/suspend_*.cpp Runtime integrations: Swoole, True Async, AMPHP, ReactPHP
src/appender.cpp Appender
src/values.cpp PHP <-> DuckDB value conversion, error taxonomy
tests/ .phpt suite + harness.php (the test entry point)
tests/stress/ Long-running memory/concurrency stress scripts
examples/ Runnable example scripts
Author
Created and maintained by Martin Juul Christiansen (juul.xyz, code@juul.xyz).
License
MIT (see LICENSE).