Python POST request and GET request: requests.post() & requests.get()

Most tutorials on a Python POST request stop at requests.post(url, data=payload) and move on. Real APIs expect custom headers, some form of auth, a timeout and code that does not fall over on the first 4xx. The same is true on the GET side, where query parameters, headers and error handling matter just as much. This guide covers the full requests post and requests get workflow, from a plain form submission to JSON payloads, file uploads, authentication, status codes and session reuse.

Installing the requests library

The requests library does not ship with Python, so install it with pip before anything below will run. It is worth doing this inside a virtual environment rather than the system Python, since that keeps the version installed for this project separate from anything else on the machine.

pip install requests

Confirm it imports correctly and check which version landed, since some of the session and retry behavior covered later depends on a reasonably recent release.

import requests
print(requests.__version__)

Requests get() and post() syntax and parameters

The full signature for a Python GET request looks like this:

requests.get(url, params=None, args)

And the signature for a Python POST request looks like this:

requests.post(url, data=None, json=None, args)

Here is what each commonly used argument does across both methods:

  • url: required. The endpoint the request goes to.
  • params: optional, used with GET. A dictionary of query string values appended to the URL.
  • data: optional, used with POST. A dictionary, list of tuples, bytes or file object sent as application/x-www-form-urlencoded.
  • json: optional, used with POST. A dictionary that requests serializes to JSON and sends with the Content-Type: application/json header set automatically.
  • headers: optional. A dictionary of custom HTTP headers to send with the request.
  • files: optional, used with POST. A dictionary of files to upload as multipart/form-data.
  • auth: optional. A tuple or auth object used for HTTP authentication.
  • timeout: optional. A number or tuple that sets how long to wait for a connection and a response before raising an exception.
  • cookies: optional. A dictionary of cookies to send with the request.
  • allow_redirects: optional. A boolean that controls whether the request follows redirects. Defaults to True.
  • verify: optional. A boolean or path that controls TLS certificate verification. Defaults to True.

Every call, whether it goes through requests.get() or requests.post(), returns a Response object, which holds the status code, headers and body of whatever the server sent back.

Sending a GET request in Python

GET is for reading data without changing anything on the server, so a plain GET call needs nothing beyond a URL.

import requests

url = "https://httpbin.org/get"

response = requests.get(url)

print(response.status_code)
print(response.text)

That already covers a working request, but most real use cases need to send some filter or identifier along with it, which is where query parameters come in.

Sending query parameters with a GET request

Rather than building a query string by hand, pass a dictionary to the params argument and requests encodes it for you.

import requests

url = "https://httpbin.org/get"
params = {"id": 123, "sort": "recent"}

response = requests.get(url, params=params)

print(response.status_code)
print(response.url)

response.url prints the full URL including ?id=123&sort=recent, which is a quick way to confirm the parameters actually made it onto the request before you go digging through a network tab. params also accepts a list of tuples instead of a dictionary, which matters when the same key needs to repeat, since a dictionary can only hold one value per key.

Reading a JSON response from a GET request

Most APIs return JSON, so call .json() on the response instead of manually parsing .text.

import requests

response = requests.get("https://httpbin.org/get", params={"id": 123})
data = response.json()

print(data["args"])

.json() raises a ValueError if the body is not valid JSON, so check response.headers.get("Content-Type") first if you are not certain what the endpoint sends back, or wrap the call in a try/except block if the response might not always be JSON.

Sending a basic POST request in Python

The simplest requests post call sends form-encoded data with the data parameter, the same format a browser sends when a person submits an HTML form. This is the pattern most beginner tutorials show first, and it still covers a fair share of real endpoints, particularly older systems and anything built around traditional HTML forms rather than a JSON API.

import requests

url = "https://httpbin.org/post"
payload = {"username": "pankaj", "role": "editor"}

response = requests.post(url, data=payload)

print(response.status_code)
print(response.text)

Requests sets the Content-Type header to application/x-www-form-urlencoded on its own here, so no manual header configuration is needed for a plain form post. The response object returned by this call holds the status code, the response body and the headers the server sent back, all of which the sections below build on.

Sending JSON data with a POST request

Most modern APIs expect JSON rather than form fields. Pass a dictionary to the json parameter instead of data, and requests handles the serialization and the header for you.

import requests

url = "https://httpbin.org/post"
payload = {"id": 101, "status": "active"}

response = requests.post(url, json=payload)

print(response.status_code)
print(response.json())

Using json= is simpler than calling json.dumps() yourself and passing the string through data=, and it avoids a common mistake: sending JSON text through data= without also setting the Content-Type header, which leaves the server treating the body as a plain string instead of parsed JSON. If you are working with JSON files rather than API payloads directly, the guide to working with JSON files in Python covers reading and writing that data locally before it reaches a request.

Sending form data with a POST request

Some endpoints, particularly login forms and legacy systems, still expect application/x-www-form-urlencoded bodies rather than JSON. The rule of thumb is straightforward: use data= for form-style submissions and json= for API payloads. Passing the wrong shape to the wrong parameter is one of the fastest ways to get a confusing error back from a server that expected the other format entirely.

import requests

login_url = "https://httpbin.org/post"
credentials = {"username": "pankaj", "password": "changeme"}

response = requests.post(login_url, data=credentials)
print(response.request.headers["Content-Type"])

That line prints application/x-www-form-urlencoded, confirming which format actually went out on the wire. Mixing up data= and json= is one of the most common reasons a request that looks correct still gets rejected with a 400 status, so checking the outgoing content type is a quick way to rule that out during debugging.

Adding headers to GET and POST requests

Custom headers matter for auth tokens, content negotiation and identifying the client making the request, on both GET and POST calls. Pass a dictionary to the headers parameter alongside the payload, or on its own for a GET call.

import requests

url = "https://httpbin.org/get"
headers = {
    "Authorization": "Bearer YOUR_TOKEN_HERE",
    "User-Agent": "codeforgeek-client/1.0",
    "Accept": "application/json",
}

get_response = requests.get(url, headers=headers)
post_response = requests.post(
    "https://httpbin.org/post",
    json={"query": "python post request"},
    headers=headers,
)

print(get_response.status_code, post_response.status_code)

A custom User-Agent header is worth setting on every outbound request, GET or POST, not just the ones sending a payload. Plenty of servers reject or throttle traffic that still carries the default requests user agent string. The Amazon scraping walkthrough shows the same header pattern applied to GET requests against a site that actively checks for it.

If you need to override a header that requests sets automatically, such as Content-Type, put it in the same headers dictionary and it takes precedence over the default.

Uploading files in a POST request

Sending a file alongside a POST request uses the files parameter, which builds a multipart/form-data body without any manual encoding work. This is the same body format a browser generates when a person picks a file in an upload form and clicks submit.

import requests

url = "https://httpbin.org/post"

with open("report.pdf", "rb") as file_handle:
    files = {"file": ("report.pdf", file_handle, "application/pdf")}
    data = {"description": "monthly report"}
    response = requests.post(url, files=files, data=data)

print(response.status_code)

Note that files and data can be combined in one call. Requests builds a single multipart body containing both the uploaded file and the regular form fields, which is exactly what most upload endpoints expect. Opening the file with open(..., "rb") inside a with block matters here too, since it guarantees the file handle closes once the request finishes, even if the upload raises an exception partway through.

Authenticating GET and POST requests

APIs generally use one of two authentication styles: HTTP Basic Auth, or a bearer token sent through a header. Both work the same way regardless of whether the call is a GET or a POST.

For Basic Auth, pass a tuple to the auth parameter.

import requests
from requests.auth import HTTPBasicAuth

get_response = requests.get("https://httpbin.org/get", auth=HTTPBasicAuth("user", "pass"))
post_response = requests.post(
    "https://httpbin.org/post",
    json={"action": "sync"},
    auth=HTTPBasicAuth("user", "pass"),
)

For token-based auth, which is far more common with REST APIs today, send the token through the Authorization header instead.

import requests

headers = {"Authorization": "Bearer sk_live_abc123"}

get_response = requests.get("https://httpbin.org/get", headers=headers)
post_response = requests.post("https://httpbin.org/post", json={"action": "sync"}, headers=headers)

Keep tokens and passwords out of source code. Load them from environment variables or a secrets manager, then read them into the script at runtime rather than hardcoding a string that ends up committed to a repository.

Handling response status codes for GET and POST

A request that reaches the server without a network error can still fail at the application level, so check the status code before trusting the response body. This applies whether the call was a GET or a POST, since both return the same Response object with the same status_code attribute.

import requests

response = requests.post("https://httpbin.org/post", json={"id": 1})

if response.status_code == 200 or response.status_code == 201:
    print("Request succeeded")
elif response.status_code == 400:
    print("Bad request, check the payload")
elif response.status_code == 401:
    print("Missing or invalid credentials")
elif response.status_code == 403:
    print("Authenticated but not allowed to do this")
elif response.status_code == 404:
    print("Resource not found, common on GET calls to a bad endpoint")
elif response.status_code == 429:
    print("Rate limited, back off and retry later")
else:
    print(f"Unexpected status: {response.status_code}")

The cleanest habit is calling response.raise_for_status() right after the request. It raises an HTTPError automatically on any 4xx or 5xx response, so a failing call never silently gets treated as good data further down the script. That single line replaces most of the manual status code checks above for scripts that just need to fail fast rather than branch on every code separately.

Handling errors and exceptions

Network calls fail in ways that have nothing to do with your code, so wrap every GET or POST call in a try/except block that catches the exceptions the library actually raises.

import requests

url = "https://httpbin.org/post"
payload = {"id": 1}

try:
    response = requests.post(url, json=payload, timeout=5)
    response.raise_for_status()
    data = response.json()
    print(data)
except requests.exceptions.Timeout:
    print("The request timed out")
except requests.exceptions.ConnectionError:
    print("Failed to reach the server")
except requests.exceptions.HTTPError as error:
    print(f"Server returned an error: {error}")
except requests.exceptions.RequestException as error:
    print(f"Request failed: {error}")

Catching RequestException last covers anything the handlers above it miss, since every exception the library raises inherits from that base class. The same block works unchanged for a requests.get() call, just swap the method on the line that sends the request. If you need to inspect the raw response text while debugging a failure, a quick check with Python’s substring search methods can confirm whether an error page or a real API response came back before you try to parse it as JSON.

Setting a timeout for GET and POST requests

Without a timeout, a hung connection can block a script indefinitely, waiting on a server that has stopped responding or a network path that has quietly dropped the connection. Always pass a timeout value, in seconds, to every request, GET or POST, rather than relying on the default, since requests has no timeout at all unless you set one.

import requests

try:
    get_response = requests.get("https://httpbin.org/get", timeout=5)
    post_response = requests.post("https://httpbin.org/post", json={"id": 1}, timeout=5)
except requests.exceptions.Timeout:
    print("Server took too long to respond")

A single number applies to both the connection phase and the read phase. Pass a tuple instead, timeout=(3, 10), to set a shorter connect timeout separately from a longer read timeout, which is useful when a slow network is more likely than a slow server that is genuinely still working on a large response.

Reusing connections with a session

Calling requests.get() or requests.post() directly opens a new TCP connection and, if the endpoint uses TLS, repeats the full handshake on every single call. A Session object reuses the underlying connection, remembers cookies between requests and lets you set default headers once instead of repeating them on every call.

import requests

session = requests.Session()
session.headers.update({"Authorization": "Bearer sk_live_abc123"})

login = session.post("https://httpbin.org/post", json={"username": "pankaj"})
login.raise_for_status()

# A later GET call through the same session reuses the connection, headers and any cookies
profile = session.get("https://httpbin.org/get", params={"action": "fetch_profile"})
profile.raise_for_status()

Session reuse matters most in scripts that send more than a handful of requests to the same host, such as a scraper, a batch import job or anything that logs in once and then performs several follow-up GET or POST calls. The performance gain comes from skipping the repeated handshake. The cookie persistence means a login POST followed by authenticated GET or POST calls just works without manually copying cookies between calls.

For scripts that hit flaky endpoints, mount a retry policy onto the session so transient failures do not need manual handling in every call site.

from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
retry_strategy = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))

By default, urllib3 retries idempotent methods like GET automatically under this policy. POST is not retried automatically, since retrying a POST can duplicate a side effect such as a form submission. Pass allowed_methods={"POST"} to the Retry object if the endpoint is safe to retry and you want that behavior turned on for POST calls as well.

GET vs POST for the same task

GET and POST both reach the same URL structure in many APIs, so it helps to be clear about when each one applies. GET is for retrieving data without changing anything on the server and any parameters belong in the query string. POST is for sending data that creates or updates something and the payload belongs in the request body instead of the URL. A search box that only reads data is often better served by a GET request with query parameters than by a POST, since GET requests are cacheable, bookmarkable and show up in server logs in a way that makes debugging easier.

Key takeaways

  • Use requests.get() with params for reading data and requests.post() for creating or updating it
  • Use data= for form-encoded POST bodies and json= for JSON payloads
  • Requests sets Content-Type automatically based on which parameter you use
  • Pass custom headers, including auth tokens, through the headers dictionary on either method
  • Always call raise_for_status() or check status_code before trusting a response
  • Wrap requests in try/except blocks that catch Timeout, ConnectionError and HTTPError
  • Always set a timeout value to avoid a script hanging indefinitely
  • Use a Session object to reuse connections and share headers across GET and POST calls
  • POST is not retried automatically by default, unlike GET

Frequently asked questions

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

data= sends a form-encoded body and expects a dictionary or string. json= serializes a dictionary to JSON automatically and sets the correct content type header for you.

How do I send query parameters with a GET request in Python?

Pass a dictionary to the params argument of requests.get(). Requests builds the query string automatically, and response.url shows the final URL sent.

How do I send a bearer token with a Python request?

Add it to the headers dictionary as {"Authorization": "Bearer YOUR_TOKEN"} and pass that dictionary through the headers parameter of requests.get() or requests.post().

Why does my POST request return a 400 error?

A 400 usually means the payload format does not match what the server expects, often from sending JSON through data= instead of json=, or a missing required field.

Does requests retry a failed POST automatically?

No. urllib3’s default retry policy only covers idempotent methods like GET. POST needs allowed_methods={"POST"} set explicitly on a Retry object mounted to a session.

What does raise_for_status() actually do?

It checks the response status code and raises an HTTPError if it is 4xx or 5xx, so failures surface immediately instead of processing an error response as valid data.

When should I use a session instead of calling requests.get() or requests.post() directly?

Use a session whenever a script sends more than one request to the same host, since it reuses the connection, persists cookies and lets you set shared headers once.

Conclusion

A python post request starts with requests.post(url, data=payload), and a GET request starts just as simply with requests.get(url, params=payload), but production code needs more than that one line either way. Headers carry authentication and identify the client, status codes and exceptions need explicit handling instead of assuming success and a session avoids the overhead of opening a fresh connection on every single call. Apply the patterns above to any API integration and the request code becomes something that fails loudly and recovers gracefully, instead of something that only works when everything on the server side goes right.

Aditya Gupta
Aditya Gupta

Aditya Gupta is a founding member and editor at CodeForGeek. He first found his way into tech by reading articles, and now writes approachable guides to Node.js security, authentication, AI tools, coding agents, and web scraping.

Articles: 529