How to Convert curl to Python Requests (and fetch)
API docs and browser dev tools hand you curl commands, but your code is in Python. Converting one to the requests library is mostly mechanical: each curl option has a matching argument. This guide shows that mapping, a worked example, the differences that trip people up, and the same request in JavaScript fetch.
Get the curl command
If the request comes from a website, open your browser's dev tools, go to the Network tab, right-click the request and choose Copy → Copy as cURL. Chrome offers two versions: bash (single quotes, $'…' for special characters) and, on Windows, cmd (with ^ escapes). Either one is fine as a starting point. It will include every header the browser sent, most of which (sec-ch-ua, accept-language and so on) you can delete.
A worked example
This curl command creates an order with a JSON body and a 10-second timeout:
curl -X POST 'https://api.example.com/v1/orders?dry_run=true' \
-H 'Authorization: Bearer TOKEN' \
-H 'Content-Type: application/json' \
-d '{"sku":"A-100","qty":2}' \
-m 10
In Python with requests (install it with pip install requests):
import requests
headers = {
"Authorization": "Bearer TOKEN",
}
json_data = {
"sku": "A-100",
"qty": 2,
}
response = requests.post(
"https://api.example.com/v1/orders?dry_run=true",
headers=headers,
json=json_data,
timeout=10,
)
print(response.status_code)
print(response.text)
Three things changed shape. -X POST became the function name (requests.post). The JSON string became a Python dict passed as json=, which serialises it and sets Content-Type: application/json for you, so that header is no longer needed. And -m 10 became timeout=10.
How each curl option maps to requests
| curl | requests | Notes |
|---|---|---|
-X POST, -X PUT, … | requests.post(), requests.put(), … | Other methods: requests.request("PURGE", url) |
-H 'Name: value' | headers={"Name": "value"} | One dict for all headers |
-d 'a=1&b=2' | data={"a": "1", "b": "2"} | A dict is form-encoded, like curl's -d |
-d '{"a":1}' with a JSON Content-Type, or --json | json={"a": 1} | Sets the JSON Content-Type itself |
--data-binary @file | data=open("file", "rb") | Sends the file's bytes as the body |
-F 'name=value' | files={"name": (None, "value")} | Multipart form field |
-F '[email protected]' | files={"file": open("photo.jpg", "rb")} | Multipart file upload |
-u user:pass | auth=("user", "pass") | HTTP Basic auth |
-b 'a=1; b=2' | cookies={"a": "1", "b": "2"} | Or keep it as a Cookie header |
-G -d 'q=x' | params={"q": "x"} | Data goes in the query string |
-m 10 | timeout=10 | Seconds |
-k | verify=False | Skips TLS checks; for testing only |
-I | requests.head() | Headers only |
-L | (default) | requests follows redirects already |
--compressed | (default) | gzip and deflate are decoded automatically |
-s, -v, -i | — | Output options with no request effect |
Forms, query strings and uploads
A login form posted with -d is form-encoded. Pass a dict and requests encodes it the same way:
curl https://api.example.com/login -d username=ada -d 'password=s3cret!'
data = {
"username": "ada",
"password": "s3cret!",
}
response = requests.post("https://api.example.com/login", data=data)
With -G, curl moves the -d data into the URL and sends a GET. In requests, either write the query string into the URL or pass params={"q": "blue shoes", "page": "2"} and let it encode the values.
A multipart upload with -F becomes files=. Fields with plain values use a (None, value) tuple so they are sent as form fields rather than files:
files = {
"file": open("report.pdf", "rb"),
"title": (None, "Q3 report"),
}
response = requests.post(
"https://api.example.com/upload",
files=files,
auth=("ada", "s3cret"),
)
Gotchas when converting
- A string body has no Content-Type. curl's
-dalways sendsContent-Type: application/x-www-form-urlencoded. In requests,data=with a dict does the same, butdata=with a plain string sends no Content-Type at all. If the API expects one, add it toheaders, or usejson=for JSON. - Don't copy a multipart Content-Type header.
Content-Type: multipart/form-dataneeds a boundary that only the library knows. Leave the header out when you usefiles=, or the server can't parse the body. - Redirects differ. curl doesn't follow redirects unless you pass
-L; requests follows them by default (onlyrequests.head()doesn't). Passallow_redirects=Falseto match curl without-L. - There is no default timeout. Without
timeout=, requests can wait forever on a server that never answers. Set one even if the curl command didn't. - Errors don't raise. A 404 or 500 is a normal response in both tools. Call
response.raise_for_status()to turn 4xx and 5xx into an exception, much like curl's-f. verify=Falseis for testing. It turns off certificate checks, just like-k. Fix the certificate, or passverify="ca.pem", before shipping.
The same request with fetch
In JavaScript (browsers, Node 18+, Deno, Bun), the order example looks like this:
const response = await fetch("https://api.example.com/v1/orders?dry_run=true", {
method: "POST",
headers: {
"Authorization": "Bearer TOKEN",
"Content-Type": "application/json"
},
body: JSON.stringify({
"sku": "A-100",
"qty": 2
}),
signal: AbortSignal.timeout(10000)
});
console.log(response.status);
console.log(await response.text());
The differences from requests: the method goes in the options object, the JSON body must be stringified and its Content-Type set by hand, and a timeout is an AbortSignal in milliseconds. A plain string body is sent as text/plain, so form data should be a URLSearchParams and uploads a FormData. fetch has no switch to skip certificate checks, and like requests it doesn't throw on 4xx or 5xx: check response.ok.
Related
Part of The Complete Guide to HTTP. To make a long command readable first, see How to Format a Long cURL Command. For the methods and headers involved, read HTTP Request Methods and How to Read HTTP Headers.
Try it
Paste a curl command into the cURL Formatter and switch to Python requests or JavaScript fetch mode to get the code. It handles headers, JSON and form bodies, uploads, auth, cookies, -G and timeouts, lists any option it can't translate as a comment, and runs entirely in your browser, so tokens in the command are never uploaded.