Skip to content

Python requests POST: form data, JSON and files

We sent every kind of POST body with Python requests to our own server and read what arrived. Four common calls send something you did not mean.

HProxy TeamOctober 12, 2026Updated October 12, 20265 min read

Python requests POST: form data, JSON and files

The requests.post() function sends data to a server: a form, a JSON body or a file. Which one the server gets depends on the argument you use, and some calls send something you did not mean without raising an error.

We sent every kind of body to a small server of our own on 11 October 2026. It answers with exactly what it received, so the results below are what arrived, not what the code seemed to say.

Send a form

import requests

URL = "http://127.0.0.1:31290/echo"  # our test server: it answers with what it received

requests.post(URL, data={"name": "Ada", "lang": "Python"}, timeout=10)

The dict arrived as name=Ada&lang=Python, with the header application/x-www-form-urlencoded: the format of an HTML form. The address is our own test server, so put your own in its place.

Send JSON

requests.post(URL, json={"name": "Ada", "tags": ["a", "b"]}, timeout=10)

With json=, requests turns the dict into JSON text and sets the header Content-Type: application/json. Use it whenever the server expects JSON.

Upload a file

requests.post(
    URL,
    data={"note": "weekly"},
    files={"file": ("report.csv", b"day,visits\nmon,12\n", "text/csv")},
    timeout=10,
)

With files=, requests sends multipart/form-data: one part for the note field and one for the file, with its name and type. Instead of the tuple, you can pass a file opened in binary mode, as the requests documentation shows.

Our test script sent each kind of body, then the four calls below, and printed what arrived:

What arrived at our test server for each kind of POST
Our own capture: post_tests.py on our server, 11 October 2026

Four calls that send something else

None of these raised an error. Each one sent a body the server would read differently from what the code seems to say.

1. JSON through data= without a header

import json

requests.post(URL, data=json.dumps({"name": "Ada"}), timeout=10)  # no Content-Type
requests.post(URL, json={"name": "Ada"}, timeout=10)  # Content-Type: application/json

Both lines send the same text, but the first sends no Content-Type header at all, so the server cannot tell that it is JSON. The requests documentation warns about exactly this: "Please note that the above code will NOT add the Content-Type header". Use json=. One of the six tutorials we read posts JSON the first way.

2. A nested dict in data=

requests.post(URL, data={"user": {"name": "Ada", "id": 7}}, timeout=10)

This arrived as user=name&user=id. The values Ada and 7 are gone. A form is flat, and requests reads the inner dict the way Python loops over any dict: it gets the keys. Send nested data with json=. None of the six tutorials we read mentions this.

3. data= and json= in the same call

requests.post(URL, data={"a": "1"}, json={"b": 2}, timeout=10)

Only a=1 arrived, and the JSON was dropped without a warning. The documentation does say so: "Note, the json parameter is ignored if either data or files is passed". Choose one: a form with data=, or JSON with json=.

4. A redirect that turns your POST into a GET

r = requests.post("http://127.0.0.1:31290/redirect/302", data={"name": "Ada"}, timeout=10)
print(r.history, r.request.method)
[<Response [302]>] GET

The server answered 302 and pointed to another address. requests followed it with a GET and dropped the body. We tried every redirect code:

The server answeredIt arrived asThe body
301GETempty
302GETempty
303GETempty
307POSTname=Ada
308POSTname=Ada

This is on purpose. A comment in the requests source reads "Do what the browsers do, despite standards". HTTP itself allows it: "For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request". If a POST seems to do nothing, check r.history and post to the final address.

Keep a login with a Session

with requests.Session() as s:
    s.post("http://127.0.0.1:31290/login", data={"user": "ada", "password": "x"}, timeout=10)
    r = s.get("http://127.0.0.1:31290/account", timeout=10)
print(r.status_code)
200

Two separate calls do not share cookies: our login with requests.post() followed by requests.get() got a 401. On one Session, the login cookie came back with the next request and the page answered 200. The documentation says a Session "persists cookies across all requests made from the Session instance".

Always set a timeout

requests has no default timeout. In the words of its documentation, "If no timeout is specified explicitly, requests do not time out". Every call on this page passes timeout=10, and only two of the six tutorials we read set one. Our guide to timeouts in requests explains what the number limits.

Send a POST through a proxy

proxies = {
    "http": "http://USER:PASS@HOST:PORT",
    "https": "http://USER:PASS@HOST:PORT",
}
requests.post(URL, data={"user": "ada", "password": "x"}, proxies=proxies, timeout=10)

Give both keys. An https:// address looks up the "https" key, and with only "http" the request skips the proxy without an error. We measured that, and a second trap with the HTTPS_PROXY variable, in our web scraping tutorial.

What the proxy sees depends on the address. We sent the same login through 3proxy, a free proxy server, on our own server:

The same login POST through a proxy, over HTTP and over HTTPS, and what the proxy logged
Diagram: HProxy, from our test on 11 October 2026

Over plain HTTP, the proxy logged the full address, POST http://127.0.0.1:31290/login. The form passed through it as plain text, password included. Over HTTPS it logged only CONNECT 127.0.0.1:31291, the host and the port. So send logins over HTTPS, through a proxy or not. Our guide to proxies with Python requests covers the setup.

What this page could not check

  • One version of requests, against our own test server. Real servers may treat a body without a Content-Type in their own way.
  • The proxy ran on the same server as the test, not on a proxy network.
  • We did not test retries, login schemes, streaming uploads or async clients.
  • We will rerun these tests with the current requests by 12 January 2027.

Sources

  • Requests documentation, Quickstart (POST requests, timeouts): requests.readthedocs.io.
  • Requests documentation, Advanced Usage (Session objects): requests.readthedocs.io.
  • requests 2.34.2 source, models.py: github.com.
  • requests 2.34.2 source, sessions.py: github.com.
  • RFC 9110, HTTP Semantics, the redirect codes (June 2022): rfc-editor.org.
  • Our own tests on our server, 11 October 2026: post_tests.py at 23:38 UTC and the code on this page at 23:46 UTC, both against our own test server, and a reading of six tutorials' code at 23:33 UTC.

Frequently asked questions

What is the difference between data and json in requests.post?

data= sends a form (application/x-www-form-urlencoded), or raw text if you pass a string. json= turns a dict into JSON and sets Content-Type: application/json. Passing json.dumps() output to data= sends the same text with no Content-Type at all, so use json= for JSON.

How do I send form data with Python requests?

Pass a dict to data=. requests encodes it as name=value pairs joined by &, with the header application/x-www-form-urlencoded. Keep the values flat: in our test a nested dict lost its values, and a list sent the key once per item.

How do I upload a file with requests.post?

Pass files= with a field name and either a file opened in binary mode or a tuple of file name, bytes and content type. Add other form fields with data=. requests then sends multipart/form-data, one part per field and file.

Why did my POST request turn into a GET?

The server answered with a redirect. After a 301, 302 or 303, requests follows the new address with a GET and drops the body, as browsers do. After a 307 or 308 it keeps the POST. Check r.history, or post to the final address.

Does requests.post have a default timeout?

No. The requests documentation says that if no timeout is given, requests do not time out. Pass timeout= on every call. It limits how long requests waits for the server to send bytes, not the whole download.

Get proxies that are alive right now

That list re-checks every exit every few minutes across 100+ countries, with a live last-checked time, so you copy IPs that worked moments ago, not a stale text dump. When the location has to survive a real check, the paid network holds up.

129M+ proxy checks run · 100+ countries · HTTP / HTTPS / SOCKS · re-checked every few minutes · no signup

HProxy.

Honest guides and comparisons on proxies, scraping and staying unblocked, from the team that runs the network.

RSS feed