Skip to main content

curlify3

PyPI Downloads Python Tests

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

  • One entrypoint for everything — to_curl() (sync) and to_curl_async() (async), dispatching on the type of the request object
  • Works with client-side requests (requests, niquests, httpx, httpx2, aiohttp, tornado, stdlib urllib.request) and server-side incoming requests (starlette / fastapi, aiohttp.web, 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=True and long option names with long_options=True
  • Header filtering per call: exclude_headers drops the noise, include_headers keeps only what matters
  • Zero runtime dependencies
  • Fully annotated and py.typed, so the types reach your own type checker

Installation

pip install curlify3

Requires Python 3.10+.

Quick start

import requests
from curlify3 import to_curl

response = requests.get("https://httpbin.org/get")
print(to_curl(response.request, pretty=True))
# curl https://httpbin.org/get \
#   -H 'user-agent: python-requests/2.34.2' \
#   -H 'accept-encoding: gzip, deflate' \
#   -H 'accept: */*' \
#   -H 'connection: keep-alive'

Usage

Every supported request object goes through the same two calls — to_curl(request) when the body is already in memory, to_curl_async(request) when it has to be await-ed. Supported request objects lists which call fits which type, and the docstring of each adapter module (curlify3/_req_*.py) carries a usage example for its library.

Logging middleware (fastapi)

On the server side, the same call turns into a one-function logging middleware that records every incoming request as a command ready to be replayed:

import logging

from fastapi import FastAPI, Request
from curlify3 import to_curl_async

logger = logging.getLogger("app.requests")

app = FastAPI()

@app.middleware("http")
async def log_request_as_curl(request: Request, call_next):
    # Reading the body in a middleware is safe here: starlette caches what
    # to_curl_async() consumed and replays it, so the route handler still
    # receives the full body (starlette >= 0.28).
    curl = await to_curl_async(request)
    # Log before handing over to the handler, so the command is captured
    # even for requests the handler then fails on — the ones worth replaying.
    logger.info("incoming request: %s", curl)
    return await call_next(request)

A POST with a JSON body arrives in the log as:

incoming request: curl -X POST -H 'host: api.example.com' -H 'accept: */*' -H 'content-type: application/json' -d '{"item":"book","qty":2}' http://api.example.com/orders

Every value in that command was chosen by the client; Quoting and untrusted values is what makes it safe to paste anyway.

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.

Filtering headers

exclude_headers leaves the named headers out of the command, include_headers renders only the named ones. Names match case-insensitively. Handy for logs, where the headers a client adds on its own drown out the ones the request is about:

import requests
from curlify3 import to_curl

req = requests.Session().prepare_request(requests.Request(
    "POST",
    "https://httpbin.org/post",
    headers={"Authorization": "Bearer t0ken"},
    json={"date": "2026-08-10"},
))

print(to_curl(req, exclude_headers=["user-agent", "accept-encoding", "accept", "connection"]))
# curl -X POST -H 'authorization: Bearer t0ken' -H 'content-type: application/json' -d '{"date": "2026-08-10"}' https://httpbin.org/post

print(to_curl(req, include_headers=["content-type"]))
# curl -X POST -H 'content-type: application/json' -d '{"date": "2026-08-10"}' https://httpbin.org/post

The two combine: include_headers narrows first and exclude_headers subtracts from what is left. include_headers=[] renders no headers at all, while the default None renders all of them. Cookie counts as a header, so a filter that drops it drops -b as well. The filter only decides what is printed: leaving out content-type hides its -H line, but a multipart body is still rendered as -F parts. A bare string such as exclude_headers="accept" raises TypeError, because it would otherwise be read one character at a time.

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.exe chokes on --%; use the default shell="sh" output there. On pwsh 7.2+, run $PSNativeCommandArgumentPassing = 'Legacy' in the session first.

The constants curlify3.SH and curlify3.POWERSHELL are exported for use instead of the raw strings.

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.

API

to_curl(request, shell="sh", pretty=False, long_options=False, include_headers=None, exclude_headers=()) -> 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, include_headers=None, exclude_headers=()) -> 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. include_headers / exclude_headers filter the rendered headers by name, see Filtering headers.

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. They raise TypeError if include_headers or exclude_headers is a str rather than an iterable of names, or holds a name that is not a str.

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; safe in middlewares, starlette replays it
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 by bash, zsh and ksh, but it is not POSIX: dash and BusyBox ash pass $\xff\xfe through literally. The dialect is named sh, but a binary body needs one of the former.
  • --data-raw, not -d: both -d and --data-binary read 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.

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.

Development

The project uses uv and just.

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.15. 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.

Metadata

Release files for curlify3 0.14

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for curlify3 0.14
File Size Uploaded
curlify3-0.14.tar.gz 21.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for curlify3 0.14
File Interpreter ABI Platform
curlify3-0.14-py3-none-any.whl Python 3 none any Details

Total release size: 47.2 kB

Release files / curlify3-0.14.tar.gz

Download URL curlify3-0.14.tar.gz
Size 21.3 kB
Tags Source
SHA-256 checksum
How to use checksums
b0a9b579290b3bdba14ef8acc183ec816d71af7dd38ed3aa1147fe8ffeb81580
BLAKE2b-256 checksum
How to use checksums
11449de997fbda748be5fcae5ce0965fef4f65ae8e10ec9cc4208f0e1acca9ab
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","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}

Release files / curlify3-0.14-py3-none-any.whl

Download URL curlify3-0.14-py3-none-any.whl
Size 25.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bc886c6ee17fd0a08711de2224438388b210246322258db2d94cb73b2844569a
BLAKE2b-256 checksum
How to use checksums
0864ee59928dc8b50dbc8a2862ed6447ed3d572b768419c5bf30fca43bc484a6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","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}

Release history Release notifications | RSS feed

This release

0.14 This release

2 release files

0.13

2 release files

0.12

2 release files

0.11

2 release files

0.10

2 release files

0.9

2 release files

0.8

2 release files

0.7

2 release files

0.6

2 release files

0.5

2 release files

0.4

2 release files

0.3

2 release files

0.2

2 release files

0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page