curlify3
Convert request objects from popular Python HTTP libraries into ready-to-run curl commands.
curlify3 takes a request object from any supported client or server framework and renders it as an equivalent curl command — useful for logging, debugging, sharing reproductions, and copy-pasting from your IDE into a terminal.
Features
- Single dispatch entrypoint —
to_curl()(sync) andto_curl_async()(async) - Works with client-side requests (
requests,niquests,httpx,httpx2,aiohttp,tornado, stdliburllib.request) and server-side incoming requests (aiohttp.web,starlette/fastapi,django,flask/werkzeug,tornado) - Faithful rendering of headers, query parameters, cookies (
-b), and bodies, quoted so the command survives the shell even when the values came from an untrusted client - Body payloads: text, JSON, form-encoded, multipart, binary
- POSIX shell output by default, Windows PowerShell output with
shell="powershell" - One-line output by default, multi-line with
pretty=Trueand long option names withlong_options=True - Zero runtime dependencies
- Fully annotated and
py.typed, so the types reach your own type checker
Installation
pip install curlify3
Requires Python 3.10+.
Comparison with curlify and curlify2
curlify |
curlify2 |
curlify3 |
|
|---|---|---|---|
requests |
✅ | ✅ | ✅ |
httpx |
❌ | ✅ | ✅ |
httpx2 (HTTP/2) |
❌ | ❌ | ✅ |
niquests |
❌ | ❌ | ✅ |
urllib.request (stdlib) |
❌ | ❌ | ✅ |
aiohttp (client and server) |
❌ | ❌ | ✅ |
tornado (client and server) |
❌ | ❌ | ✅ |
starlette / fastapi (server-side) |
❌ | ❌ | ✅ |
django (server-side) |
❌ | ❌ | ✅ |
flask / werkzeug (server-side) |
❌ | ❌ | ✅ |
| Async API | ❌ | ❌ | ✅ |
| Python | 3.7+ | 3.7–3.11 | 3.10+ |
curlify is the original and covers only requests. curlify2 added httpx but is sync-only, client-side-only, and has not seen a release since 2023. curlify3 extends the same idea across the ecosystem: HTTP/2 (httpx2), an async entrypoint, the rest of the popular clients down to the stdlib's urllib.request, and server-side adapters for aiohttp, starlette / fastapi, django, flask / werkzeug and tornado so you can dump incoming requests as curl from inside a handler.
Quick start
import requests
from curlify3 import to_curl
response = requests.get("https://httpbin.org/get")
print(to_curl(response.request))
# curl -H 'user-agent: python-requests/2.32.3' -H 'accept-encoding: gzip, deflate' \
# -H 'accept: */*' -H 'connection: keep-alive' https://httpbin.org/get
Usage
requests
import requests
from curlify3 import to_curl
req = requests.Request(
"POST",
"https://httpbin.org/post",
json={"hello": "world"},
).prepare()
print(to_curl(req))
niquests
The prepared request mirrors requests, and so does the call. HTTP/2 and HTTP/3 are negotiated on the transport, so the command carries no --http2.
import niquests
from curlify3 import to_curl
req = niquests.Request(
"POST",
"https://httpbin.org/post",
json={"hello": "world"},
).prepare()
print(to_curl(req))
httpx (sync)
import httpx
from curlify3 import to_curl
req = httpx.Request("POST", "https://httpbin.org/post", json={"hello": "world"})
print(to_curl(req))
httpx (async)
import asyncio
import httpx
from curlify3 import to_curl_async
async def main():
req = httpx.Request("POST", "https://httpbin.org/post", json={"a": 1})
print(await to_curl_async(req))
asyncio.run(main())
httpx2 — HTTP/2
The generated command includes --http2.
import httpx2
from curlify3 import to_curl
req = httpx2.Request("GET", "https://httpbin.org/get")
print(to_curl(req))
# curl --http2 -H 'host: httpbin.org' https://httpbin.org/get
to_curl_async() works with httpx2.Request too.
urllib.request — stdlib
No third-party client required. A request without an explicit method renders the one urllib would send: POST when it carries data, GET otherwise.
import urllib.request
from curlify3 import to_curl
req = urllib.request.Request(
"https://httpbin.org/post",
data=b'{"hello": "world"}',
headers={"Content-Type": "application/json"},
)
print(to_curl(req))
# curl -X POST -H 'content-type: application/json' -d '{"hello": "world"}' https://httpbin.org/post
tornado — client-side
import tornado.httpclient
from curlify3 import to_curl
req = tornado.httpclient.HTTPRequest(
"https://httpbin.org/post",
method="POST",
body='{"hello": "world"}',
)
print(to_curl(req))
Readable output
pretty=True puts every option on its own line, and long_options=True spells the options out (--header instead of -H). They are independent, so either can be used alone.
import requests
from curlify3 import to_curl
req = requests.Request(
"POST",
"https://httpbin.org/post",
json={"date": "2026-08-10"},
).prepare()
print(to_curl(req, pretty=True, long_options=True))
# curl https://httpbin.org/post \
# --request POST \
# --header 'content-type: application/json' \
# --data '{"date": "2026-08-10"}'
The url moves to the first line, where curl reads it just as well as in the trailing position — the same layout Chrome DevTools' "Copy as cURL" produces. A request with no options stays on one line.
pretty=True is rejected with a ValueError for shell="powershell": the --% token that dialect relies on is effective only until the next newline, and a backtick cannot extend it, so a multi-line command would be passed to curl.exe in pieces.
Quoting, and untrusted values
Every rendered value is quoted for the target shell, so a body, header, cookie or url is data and never becomes part of the command. This matters most for the server-side adapters, where all of those arrive from the client:
# an incoming request whose path and cookie were chosen by the caller
print(await to_curl_async(request))
# curl -b 'n=O'\''Brien' -H 'host: example.com' 'http://example.com/x;id'
The url and the cookie header are left bare when every character in them is safe, which is the common case and keeps the command short. Anything else is quoted — including the ? of a single-parameter query string, which zsh would otherwise reject as an unmatched glob.
Windows PowerShell
By default the command is formatted for POSIX shells. Pass shell="powershell" to get one that pastes into Windows PowerShell 5.1.
import requests
from curlify3 import to_curl
req = requests.Request(
"POST",
"https://httpbin.org/post",
json={"date": "2026-08-10"},
).prepare()
print(to_curl(req, shell="powershell"))
# curl.exe --% -X POST -H "content-type: application/json" -d "{\"date\": \"2026-08-10\"}" "https://httpbin.org/post"
curl.exe avoids the Invoke-WebRequest alias, and --% — PowerShell's stop-parsing token — hands the rest to curl.exe verbatim. The token is what makes arbitrary JSON survive: without it, 5.1's argument binder re-quotes values by counting every double quote, escaped or not, and mangles the body. Two consequences worth knowing:
%NAME%environment-variable references in a payload are still expanded.- The command is for PowerShell only — in
cmd, git-bash, or WSL,curl.exechokes on--%; use the defaultshell="sh"output there. Onpwsh7.2+, run$PSNativeCommandArgumentPassing = 'Legacy'in the session first.
The constants curlify3.SH and curlify3.POWERSHELL are exported for use instead of the raw strings.
aiohttp — client-side
Client middlewares (aiohttp 3.12+) are where an outgoing aiohttp.ClientRequest is reachable. Rendering the command does not consume the payload — in-memory bodies hand back their value, files seek back, and async iterables are cached and replayed when the request is sent (that non-consuming read needs aiohttp 3.12.1+; below it, streaming bodies render without -d).
import asyncio
import aiohttp
from curlify3 import to_curl_async
async def log_as_curl(request, handler):
print(await to_curl_async(request))
return await handler(request)
async def main():
async with aiohttp.ClientSession(middlewares=(log_as_curl,)) as session:
await session.post("https://httpbin.org/post", json={"hello": "world"})
asyncio.run(main())
aiohttp — server-side
Render an incoming request inside a handler. The async variant is required because the body is read from the stream.
from aiohttp import web
from curlify3 import to_curl_async
async def handler(request: web.Request) -> web.Response:
curl = await to_curl_async(request)
print(curl)
return web.json_response({"ok": True})
starlette / fastapi — server-side
from fastapi import FastAPI, Request
from curlify3 import to_curl_async
app = FastAPI()
@app.post("/echo")
async def echo(request: Request):
curl = await to_curl_async(request)
return {"curl": curl}
django — server-side
Django buffers the body before the view runs, so the sync to_curl() is enough — including inside async views. If the stream was consumed without buffering (multipart parsing, request.read()), the command carries the headers but no -d.
from django.http import JsonResponse
from curlify3 import to_curl
def echo(request):
return JsonResponse({"curl": to_curl(request)})
flask / werkzeug — server-side
The adapter targets werkzeug.wrappers.Request, which covers Flask through its Werkzeug base — plain Werkzeug apps work the same way.
import flask
from curlify3 import to_curl
app = flask.Flask(__name__)
@app.post("/echo")
def echo():
return {"curl": to_curl(flask.request)}
tornado — server-side
The framework reads the body before the handler runs, so the incoming request renders synchronously.
import tornado.web
from curlify3 import to_curl
class EchoHandler(tornado.web.RequestHandler):
def post(self):
self.write({"curl": to_curl(self.request)})
API
to_curl(request, shell="sh", pretty=False, long_options=False) -> str
Render a request object as a curl command. Use for synchronous client-side request types (requests.PreparedRequest, niquests.PreparedRequest, httpx.Request, httpx2.Request, urllib.request.Request, tornado.httpclient.HTTPRequest) and for server-side requests whose body the framework has already buffered (django.http.HttpRequest, werkzeug.wrappers.Request / flask.Request, tornado.httputil.HTTPServerRequest).
to_curl_async(request, shell="sh", pretty=False, long_options=False) -> str
Async variant. Use for request objects whose body must be await-ed (aiohttp.web.Request, aiohttp.ClientRequest, starlette.requests.Request) or when you prefer the async pathway for httpx / httpx2.
shell selects the output dialect: "sh" (default, POSIX shells) or "powershell" (Windows PowerShell 5.1; for pwsh 7.2+ see the PowerShell section). pretty breaks the command across lines, long_options spells the options out; both default to False, which keeps the output on a single line with short options.
Both functions raise ValueError if the request type or the shell value is not recognized, if pretty=True is combined with shell="powershell", if the body — or a multipart field value — is not valid UTF-8 and shell="powershell" (raw bytes have no spelling behind the --% token), or if either contains a NUL byte.
Supported request objects
| Library | Type | to_curl |
to_curl_async |
Notes |
|---|---|---|---|---|
requests |
PreparedRequest |
✅ | — | Pass Request(...).prepare() |
niquests |
PreparedRequest |
✅ | — | Pass Request(...).prepare(); HTTP/2 and HTTP/3 live on the transport, so no --http2 |
httpx |
httpx.Request |
✅ | ✅ | |
httpx2 |
httpx2.Request |
✅ | ✅ | Adds --http2 |
urllib.request |
urllib.request.Request |
✅ | — | stdlib; an absent method is inferred the way urllib sends it |
aiohttp |
aiohttp.web.Request |
— | ✅ | Server-side, body is read from the stream |
aiohttp |
aiohttp.ClientRequest |
— | ✅ | Client-side, reachable in client middlewares (aiohttp 3.12+, non-consuming body read 3.12.1+) |
starlette / fastapi |
starlette.requests.Request |
— | ✅ | Server-side, body is read from the stream |
django |
django.http.HttpRequest |
✅ | — | Server-side, body already buffered; a consumed stream renders without -d |
flask / werkzeug |
werkzeug.wrappers.Request |
✅ | — | Server-side; covers Flask through its Werkzeug base |
tornado |
tornado.httpclient.HTTPRequest |
✅ | — | Client-side |
tornado |
tornado.httputil.HTTPServerRequest |
✅ | — | Server-side, body already read |
Payload handling
| Payload | Rendered as |
|---|---|
| Plain text | -d 'text' |
| JSON | -d '{"k":"v"}' with content-type: application/json |
| Form-encoded | -d 'k=v&k2=v2' with content-type: application/x-www-form-urlencoded |
| Multipart / files | -F 'field=@file' -F 'other=value', in the order the body carries the parts |
| Binary | --data-raw $'\xff\xfe' when the body is not valid UTF-8 |
| File reference | --data-raw '@name' / --form-string 'field=@name' when a value starts with @ or < |
| Cookies | -b k=v (lifted out of the Cookie header, quoted when it needs it) |
| Headers | -H 'name: value' (lowercased) |
Content-Length is dropped. If a body is present without Content-Type, content-type: text/plain is added so curl does not guess.
A body that does not decode as UTF-8 is rendered as an ANSI-C quoted literal, with only the bytes that have to be escaped escaped — so a mis-encoded text body stays readable as --data-raw $'caf\xe9'. Two things follow from that:
$'…'is understood bybash,zshandksh, but it is not POSIX:dashand BusyBoxashpass$\xff\xfethrough literally. The dialect is namedsh, but a binary body needs one of the former.--data-raw, not-d: both-dand--data-binaryread a leading@as a filename to load the body from, and@is an ordinary byte in a binary payload.
A body containing a NUL byte raises ValueError. A command-line argument is NUL-terminated, so no quoting can carry one — a command that ran and silently sent a truncated body would be worse than one that refuses to be rendered. The same applies to a multipart field value, which reaches the command line the same way.
curl reads a leading @ in a --data value, and a leading @ or < in a --form value, as the name of a local file to send the contents of rather than as the value itself. A request whose body or form field genuinely starts with one of those characters is therefore rendered with the option that takes the value literally — --data-raw and --form-string — so the command sends what the request carried:
print(to_curl(httpx.Request("POST", "https://example.com/", content="@/etc/passwd")))
# curl -X POST -H 'host: example.com' -H 'content-type: text/plain' \
# --data-raw '@/etc/passwd' https://example.com/
This matters most on the server side, where the value is chosen by whoever sent the request: with -d, a command rendered into a log and later pasted into a terminal would read a local file of the caller's choosing and send it to the caller's own url. File parts keep -F 'field=@file', where the @ is the intended meaning.
Development
uv sync --group dev
just tests # pytest
just lint # ruff format --check, ruff check, ty check
just fmt # ruff check --fix, then ruff format
just fmt runs the linter before the formatter on purpose: a --fix can leave code the formatter still has to lay out.
Formatting and linting are handled by ruff, type checking by ty.
One convention the tooling cannot enforce on its own: every parameter of a function goes on its own line, which means every parameter list ends with a trailing comma. Write the comma and the formatter keeps the layout.
CI runs the linter and the type checker on every pull request, and the test suite on Python 3.10–3.14. A separate windows-latest job runs the end-to-end tests against the real powershell.exe 5.1 and pwsh, so the PowerShell dialect is verified by the shell it targets rather than by string comparison alone. The POSIX end-to-end tests do the same through bash: the generated command is executed and a local server checks the request arrived byte-for-byte.
Changelog
See CHANGELOG.md.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file curlify3-0.12.tar.gz.
File metadata
- Download URL: curlify3-0.12.tar.gz
- Upload date:
- Size: 19.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
98d6db514d5d020b6eb81fb2105e977f5cc62653bace2764b7a6ffa5a7c050e9
|
|
| MD5 |
edb914f1d322e989cfb9231ece5282c4
|
|
| BLAKE2b-256 |
1ed9255a4579feae1e7464be147e8299b837f932f05669ac85c2b6be4b66b913
|
File details
Details for the file curlify3-0.12-py3-none-any.whl.
File metadata
- Download URL: curlify3-0.12-py3-none-any.whl
- Upload date:
- Size: 22.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6d3a217f6e59ef088d2edb9c89dca6c51e609073c777a58fa3ccceaaf9948b0d
|
|
| MD5 |
a8c539a4a0ef26a0eacab9820f6089f0
|
|
| BLAKE2b-256 |
f811a9071009c68af4bdd34a5ea27be8fc4b34689637bc5b47992acc00adbf31
|