On this page
Technical Overview
A REST call and a row, one SELECT apart
HTTP Client closes the smallest possible gap between a REST API and a DuckDB row: instead of standing up a Python or Node.js sidecar to fetch a payload and load it back in, you call a scalar function inside a SELECT and get the response as a JSON value you can extract, cast, and join on the spot. That tight coupling is its whole reason to exist β and also the source of every caveat below. It is built for ad-hoc API calls and low-volume per-row enrichment, not for high-throughput or streaming integrations.
How it works
The extension is a deliberately thin SQL surface over a single synchronous HTTP request β there is no ATTACH, no secrets type, no connection pool, no background fetcher, and no retry machinery. Each function call opens a connection, issues the request, blocks until the full response is in memory, and returns it as a JSON struct. Because the function shape is stable and side-effect-free at the SQL layer, the result composes cleanly into CTEs, lateral joins, and CREATE TABLE AS β anywhere a scalar JSON value is acceptable.
- β’ Synchronous and blocking: DuckDB stalls on each call until the response arrives or the underlying request fails β there is no per-row timeout knob and no async dispatch. A slow endpoint slows the whole query, and rows are processed in whatever order DuckDB chooses to evaluate the scalar.
-
β’
One call per row: Per-row enrichment issues exactly one HTTP request per input row, sequentially, with no connection reuse exposed at the SQL layer. Fine for tens or hundreds of rows; for tens of thousands it is slow and very likely to get rate-limited upstream. Pre-aggregate to distinct keys or cache responses into a table with
CREATE TABLE ASbefore joining at scale. -
β’
JSON response struct: Every call returns a JSON object shaped
{status, reason, body}βstatusis the numeric HTTP code,reasonis the HTTP reason phrase ("OK","Not Found"), andbodyis always a string. Pull fields out with->>; to drill into a JSON response body, cast it first:((res->>'body')::JSON)->>'$.field'. -
β’
Headers and params are MAPs: Headers and POST parameters are passed as DuckDB
MAPliterals βMAP{'key': 'value', ...}. Headers go on the wire as-is; how the params map is encoded into the body (JSON vs. URL-encoded form) depends on which POST function you call.
Scope and caveats
The extension is honest-by-design about what it does not do. Read this before pointing it at production traffic β for anything it lacks (streaming, complex auth, retries, parallelism), keep a real HTTP client or shell out to curl via shellfs.
- β’ Experimental status: Marked experimental by the upstream README β "USE AT YOUR OWN RISK!". Pin a known-good extension version in any pipeline that depends on it.
-
β’
GET and POST only: The documented surface covers
GETandPOST(JSON or form-encoded) β noPUT,PATCH, orDELETE. For other HTTP methods, shell out tocurlvia shellfs. -
β’
No retries, backoff, or streaming: The request is issued once and the entire response body is buffered into memory β there is no automatic retry, no backoff, and no streaming read of large responses. Build retry/backoff in your application layer, and prefer shellfs +
curlpiped throughread_csv/read_jsonwhen a response is too big to hold in memory. -
β’
Credentials are visible in the query plan: Any token or secret inlined into a
headersorparamsmap appears in the cached query plan andEXPLAINoutput. Source credentials from the environment withgetenvand bind them throughSET VARIABLEβgetenvresolves at parse time, so the literal value never lands in a cached plan or session log.
Deep Dive
Technical Details
What you can do with one query
The shortest path from βREST APIβ to βDuckDB rowβ:
SELECT ((http_get('https://api.github.com/repos/duckdb/duckdb')->>'body')::JSON)->>'$.stargazers_count' AS stars;http_get issues a synchronous HTTP GET, returns the response as a JSON object with status / reason / body, and lets you cast and chain ->> right where you need the field. No Python wrapper, no curl in the middle, no temp file.
This extension is for ad-hoc API calls and low-volume per-row enrichment from inside SQL. It supports GET and POST (JSON or form-encoded) β no PUT / PATCH / DELETE in the documented surface.
Calls are synchronous: DuckDB blocks until the response arrives. There is no retry, no backoff, no streaming, no per-row timeout knob β the entire response body is buffered into memory. One HTTP request per input row, sequentially. For high-throughput integrations or streaming responses, keep a real HTTP client. For curl-only features (cookies, custom TLS, complex auth), use shellfs instead.
Status is experimental β pin a known-good extension version in any pipeline that depends on it.
Architecture
The extension is a thin SQL surface over a synchronous HTTP request. There is no ATTACH, no secrets type, no connection pool, no background fetcher β just three scalar functions:
http_get(url)βGETrequest, no headers parameter in the documented signature.http_post(url, headers, params)βPOSTwith theparamsMAPJSON-encoded into the body.http_post_form(url, headers, params)βPOSTwithparamsURL-encoded asapplication/x-www-form-urlencoded.
Each call returns a JSON object β {status, reason, body} β that you extract with DuckDBβs JSON operators. The body is always a string; cast it with (body)::JSON if the response is JSON and you want to drill in.
Because the function shape is stable and synchronous, the result of an http_* call composes cleanly into CTEs, lateral joins, and CREATE TABLE AS β anywhere a scalar JSON value is acceptable. The cost is the obvious one: one HTTP call per evaluation, in the order DuckDB chooses to evaluate the scalar.
Securing credentials
Any value passed inline to headers or params ends up in the cached query plan and EXPLAIN output. For Bearer tokens, OAuth client secrets, and other credentials, source them from the environment with getenv and bind them via SET VARIABLE:
SET VARIABLE api_token = getenv('API_TOKEN');
SELECT http_post( 'https://api.example.com/users', headers => MAP{ 'authorization': 'Bearer ' || :api_token, 'content-type': 'application/json' },);getenv resolves at parse time, so the literal credential never appears in any cached query plan or session log. The same pattern works for OAuth client credentials passed to http_post_form β see the Cookbook for that recipe.
Scoping per-row calls
Per-row enrichment is the most common reason to reach for this extension, and the most common reason to regret it. A query like
SELECT u.id, http_get('https://api.example.com/score/' || u.id::VARCHAR) AS rFROM users u;issues one HTTP call per row, sequentially, with no connection reuse exposed at the SQL layer. For ten rows itβs instant; for ten thousand rows itβs slow and very likely rate-limited by the upstream server.
Practical guardrails:
- Pre-aggregate to a small set of distinct keys before joining against the API. If
usershas 100k rows but only 200 unique scores, fetch the 200 unique scores into a CTE first. LIMITaggressively during development to avoid accidentally hammering an endpoint while you iterate.- Cache results into a DuckDB table with
CREATE TABLE ASβ repeat queries hit the cache, not the API. - Sleep is not exposed. If you need rate limiting between calls, run the query in batches from your application layer rather than in one large SQL statement.
For genuinely large fetches, prefer one bulk call that returns many rows over many small per-row calls β most REST APIs offer a list endpoint for that reason.
Compared to alternatives
- shellfs +
curlβ pipecurloutput throughread_csv/read_json. Use shellfs when you need streaming reads of large responses (HTTP Client buffers the whole body), curl-only features (cookies, complex TLS, client certs), or shell-pipeline composition withawk/jq. Use HTTP Client when the response is small and you want the result as a JSON value directly inside aSELECT. - httpserver β the inverse direction: serve DuckDB query results as an HTTP API rather than consume one. Pair them when you want one DuckDB instance to fetch from upstream APIs and another to expose results to downstream consumers.
- Application-layer Python / Node + DuckDB β appropriate when you need retries, parallelism, connection reuse, or any HTTP feature this extension doesnβt expose. The tradeoff is the boilerplate and the round-trip back into SQL.
Install
INSTALL http_client FROM community;
LOAD http_client;
Quick Start
GET a URL and read the status code
SELECT (http_get('https://httpbin.org/get')->>'status')::INT AS status;
POST a JSON body
SELECT http_post(
'https://httpbin.org/post',
headers => MAP{'accept': 'application/json'},
params => MAP{'name': 'alice'}
);
POST as application/x-www-form-urlencoded
SELECT http_post_form(
'https://httpbin.org/post',
headers => MAP{},
params => MAP{'limit': '10'}
);
Reference
Extension Contents
Quick reference to all available functions and settings organized by category.
| Name | Type | Description |
|---|---|---|
|
Requests
Issue HTTP requests from DuckDB SQL. |
||
| http_get() |
Issue an HTTP GET against url and return the response as JSON.
|
|
| http_head() |
Issue an HTTP HEAD request β same response metadata as a GET, but without a body.
|
|
| http_post() |
Issue an HTTP POST with the params map JSON-encoded as the request body.
|
|
| http_post_form() |
Issue an HTTP POST with the params map encoded as application/x-www-form-urlencoded β the body shape used by HTML forms, many older REST APIs, and OAuth 2.0 token endpoints.
|
|
No extension contents match that search.
API Reference
Function Documentation
Practical Examples
Cookbook
Real-world recipes and patterns for common use cases.
Read the response shape
Every http_* function returns a JSON object with three fields. Pull them out with ->>:
WITH r AS ( SELECT http_get('https://httpbin.org/get') AS res)SELECT (res->>'status')::INT AS status, (res->>'reason') AS reason, res->>'body' AS bodyFROM r;To dive into a JSON response body, cast it on the way out: ((res->>'body')::JSON)->>'$.field'. See http_get for the full signature.
GET a JSON API and project a field
SELECT ((http_get('https://api.github.com/repos/duckdb/duckdb')->>'body')::JSON)->>'$.stargazers_count' AS stars;Same pattern for any read endpoint that returns JSON β fetch, cast body to JSON, project with ->>.
POST a JSON body
params becomes the JSON body. Set content-type so the server knows to parse it as JSON:
SELECT http_post( 'https://api.example.com/users', headers => MAP{ 'accept': 'application/json', 'content-type': 'application/json' }, params => MAP{ 'name': 'Alice', });See http_post.
POST as application/x-www-form-urlencoded
For HTML forms, older REST APIs, and most OAuth 2.0 token endpoints. The extension sets the Content-Type for you β you donβt need to put it in headers:
SELECT http_post_form( 'https://oauth.example.com/token', headers => MAP{}, params => MAP{ 'grant_type': 'client_credentials', 'client_id': :client_id, 'client_secret': :client_secret });See http_post_form.
Authorize with a Bearer token from the environment
Source credentials from the environment with getenv and bind them via SET VARIABLE so they donβt leak into cached query plans or EXPLAIN output:
SET VARIABLE api_token = getenv('API_TOKEN');
SELECT http_post( 'https://api.example.com/comments', headers => MAP{ 'authorization': 'Bearer ' || :api_token, 'content-type': 'application/json' }, params => MAP{'body': 'Hello from DuckDB'});Exchange OAuth credentials for an access token
The classic application/x-www-form-urlencoded handshake β followed by a JSON parse on the response body to lift the access token out:
SET VARIABLE client_id = getenv('OAUTH_CLIENT_ID');SET VARIABLE client_secret = getenv('OAUTH_CLIENT_SECRET');
WITH t AS ( SELECT http_post_form( 'https://oauth.example.com/token', headers => MAP{}, params => MAP{ 'grant_type': 'client_credentials', 'client_id': :client_id, 'client_secret': :client_secret } ) AS res)SELECT ((res->>'body')::JSON)->>'$.access_token' AS access_tokenFROM t;Per-row enrichment from a REST API
SELECT u.id, u.email, ((http_get('https://api.example.com/score/' || u.id::VARCHAR)->>'body')::JSON)->>'$.score' AS scoreFROM users uWHERE u.last_seen > now() - INTERVAL 1 HOURLIMIT 100;Practical for tens or hundreds of rows. For thousands, pre-aggregate to distinct keys first or cache the result into a DuckDB table β one HTTP call per row adds up fast.
Cache responses into a table
Hit the API once, query the cache forever after:
CREATE TABLE api_scores ASSELECT u.id, ((http_get('https://api.example.com/score/' || u.id::VARCHAR)->>'body')::JSON)->>'$.score' AS scoreFROM (SELECT DISTINCT id FROM users) u;Re-run this only when you want a refresh. Subsequent joins against api_scores are local and fast.
Conditional logic on the status code
Branch in SQL based on the HTTP status β use CASE on the status field:
WITH r AS ( SELECT http_get('https://api.example.com/things/42') AS res)SELECT CASE (res->>'status')::INT WHEN 200 THEN 'ok' WHEN 404 THEN 'missing' WHEN 429 THEN 'rate-limited' ELSE 'unexpected' END AS outcome, res->>'body' AS bodyFROM r;Liveness check with HEAD
SELECT (http_head('https://httpbin.org/')->>'status')::INT AS status;http_head returns the same response metadata as http_get but the server skips the body. Useful for βis this URL reachable?β probes before a heavier GET.
Trigger a webhook from a SQL pipeline
End a pipeline by POSTing a summary to a webhook β synchronous, so the webhook either succeeds or the surrounding statement sees the error:
SET VARIABLE hook = getenv('WEBHOOK_URL');
SELECT http_post( :hook, headers => MAP{'content-type': 'application/json'}, params => MAP{ 'event': 'pipeline_complete', 'rows': (SELECT count(*)::VARCHAR FROM staging), 'finished': now()::VARCHAR });Platform Support
Compatibility
Extension availability may vary by platform and DuckDB version. Check below to ensure this extension supports your environment before installation.
Quick Facts
Platforms
- Linux x86_64 aarch64
- Linux (musl) Not available
- macOS Intel Apple Silicon
- Windows x86_64
- WASM Not available
Compiled binary sizes
| Platform | Architecture | Size |
|---|---|---|
| Linux | x86_64 | 6.10 MB |
| Linux | aarch64 | 6.03 MB |
| macOS | Intel | 3.60 MB |
| macOS | Apple Silicon | 3.74 MB |
| Windows | x86_64 | 9.35 MB |
Compressed download size from the Haybarn extension repository.
DuckDB & Haybarn
Release calendar- DuckDB v1.5.5 Haybarn 1.5.5-rc1 Supported