Python Requests Library Explained — HTTP, APIs and Real-World Patterns
- Always call
response.raise_for_status()before accessing response data — Requests does not raise exceptions on 404 or 500 by default. Skipping this call means your code will silently process error responses as valid data, which causes downstream bugs that surface far from the original API call and are hard to trace. - Use json=payload, not data=json.dumps(payload) — the json= parameter handles serialisation and sets the Content-Type header in a single argument. The data= version sends the right body bytes but the wrong Content-Type, which causes most REST APIs to return a 400 with no useful diagnostic information.
- Use requests.
Session()for any production code making multiple requests to the same host — it reuses TCP connections via connection pooling, persists auth headers and cookies across all calls, and is the correct foundation for mounting retry strategies. Barerequests.get()is for one-off scripts.
- Requests is the industry-standard Python HTTP library — one readable line to make GET, POST, and other HTTP calls
- Always call response.raise_for_status() — requests.get() silently returns 404/500 without raising an exception
- Use json=payload not data=json.dumps(payload) — json= handles serialisation and Content-Type header automatically
- Use requests.Session() for multiple calls to the same host — reuses TCP connections, persists headers and auth
- Always set timeout=(connect, read) — without it, requests.get() waits forever and can hang your entire app
- Biggest production trap: processing error responses as valid data because you never checked the status code
Script hangs on HTTP call with no output and no error
python -c "import requests; r=requests.get('https://httpbin.org/delay/30', timeout=3); print(r.status_code)"timeout 5 python -c "import requests; requests.get('https://httpbin.org/delay/30')"POST returns 400 with a payload that looks correct when printed
python -c "import requests; r=requests.post('https://httpbin.org/post', json={'key':'val'}); print(r.request.headers.get('Content-Type'))"python -c "import requests, json; r=requests.post('https://httpbin.org/post', data=json.dumps({'key':'val'})); print(r.request.headers.get('Content-Type'))"Connection pool exhaustion — too many open files or socket errors under concurrent load
python -c "import requests; s=requests.Session(); print(s.adapters)"lsof -p $(pgrep -f your_script.py) | grep TCP | wc -lIntermittent 502 or 503 errors from upstream API — not consistent, just occasional
python -c "from urllib3.util.retry import Retry; print(Retry.DEFAULT_BACKOFF_MAX)"python -c "import requests; from requests.adapters import HTTPAdapter; from urllib3.util.retry import Retry; s=requests.Session(); s.mount('https://', HTTPAdapter(max_retries=Retry(3, backoff_factor=1))); print('Retry configured on session')"Production Incident
requests.post() call because it had never been needed before.requests.post() call in the service blocked its thread for the full 90 seconds waiting for a response that was crawling back. The service ran with 200 worker threads. Within less than two minutes, all 200 were blocked mid-request. No threads remained to process incoming payments, respond to health check endpoints, or handle Kubernetes liveness probes. The pods were restarted but immediately consumed their thread pools on the backlog of queued requests. The service appeared alive — process running, no crashes, no exceptions — and was completely dead.requests.post() wait forever by default — a missing timeout is a production time bomb, not a minor oversightOne slow upstream can consume your entire thread pool in seconds — connection pool limits are a required safeguard, not a premature optimisationA process that is running but doing nothing is significantly harder to diagnose than a process that has crashed — add thread-level health metrics and alert on thread pool saturation, not just process livenessCircuit breakers prevent cascade failures — once you know an upstream is failing, stop calling it immediately and fail fast rather than queuing more blocked threadsLoad test with latency injection, not just load — a dependency that responds in 200ms under normal conditions and 90 seconds under stress will not reveal this class of failure in a happy-path load testProduction Debug GuideFrom silent failures to connection exhaustion — what to check first and why
requests.get() and requests.post() wait forever without a timeout — there is no default. If this is happening in production, also check whether your thread pool or worker count is saturated, because one hanging call usually means many.raise_for_status() before every .json() call.requests.get() in a loop or thread pool. Each call opens a new TCP connection and new file descriptor. Switch to requests.Session() and configure HTTPAdapter with explicit pool_connections and pool_maxsize values. Check current open file descriptors with lsof -p <pid> | grep TCP | wc -l.Every modern application talks to the internet. Whether it's pulling live weather data, posting a support ticket, authenticating a user through OAuth, scraping pricing data, or integrating with a payment gateway — your Python code needs a reliable, human-friendly way to make HTTP requests. The Requests library has been that solution for over a decade. It's downloaded more than 300 million times a month, it ships as a dependency in more Python projects than almost anything else, and it's the first thing most engineers reach for when they need to talk to an API.
But Requests has a deceptive learning curve. The basics look trivially simple — one line and you have a response. That simplicity is the trap. Most tutorials stop at requests.get() and show you a happy path. Production systems don't get happy paths. They get slow gateways, flaky upstreams, expired tokens, malformed JSON in 200 responses, thread pools exhausted by a single hanging call, and credential leaks from keys hardcoded three years ago by someone who no longer works there.
This guide covers all of it. The correct patterns, the failure modes that actually bite teams in production, the debugging steps when things go wrong at 2am, and the mental models that help you make the right call without having to re-read the docs every time.
GET Requests — Asking the Internet for Data
A GET request is the most fundamental HTTP action. When you type a URL into your browser, that is a GET request. The browser is saying: 'Hey server, give me this resource.' With the Requests library, you replicate that in a single readable line of Python.
But a response is more than just the data you asked for. It's a complete package — a status code that tells you whether the request succeeded, headers that carry metadata about the response, and a body that contains the actual content. Requests gives you clean access to all three.
The .json() method deserves special mention. It automatically parses the JSON response body into a Python dictionary. This is better than calling json.loads(response.text) yourself — it's shorter, it's cleaner, and it raises a clear JSONDecodeError if the body is not valid JSON instead of silently returning garbage.
Here is the thing that catches every beginner: requests.get() does not raise an exception when the server returns a 404 or 500. It returns that error response just as cheerfully as it returns a 200. If you skip the status check and call .json() on a 404 HTML error page, you get a ValueError. If the server returns a 500 with a JSON error object and you treat it as real data, you get subtle corruption downstream that surfaces an hour later in a completely different part of your system. Always check the status before trusting the body.
import requests # GitHub's public API — no auth required for basic profile lookups # This is a real endpoint you can run right now GITHUB_API_URL = "https://api.github.com/users/torvalds" # Make the GET request — Requests handles TCP connection setup, # HTTP header negotiation, response buffering, and decoding response = requests.get( url=GITHUB_API_URL, timeout=(3, 10) # 3s connect timeout, 10s read timeout — always set this ) # raise_for_status() raises requests.exceptions.HTTPError for any 4xx or 5xx # Call this before you touch the response body — always response.raise_for_status() # .json() parses the JSON body into a Python dict automatically # No need for json.loads(response.text) — that's the old way user_profile = response.json() print(f"Name : {user_profile['name']}") print(f"Public Repos: {user_profile['public_repos']}") print(f"Followers : {user_profile['followers']}") print(f"Profile URL : {user_profile['html_url']}") # The Response object gives you full access to the HTTP layer print(f"\nStatus Code : {response.status_code}") print(f"Content-Type: {response.headers['Content-Type']}") print(f"Rate Limit : {response.headers.get('X-RateLimit-Remaining', 'N/A')} requests remaining")
Public Repos: 6
Followers : 237981
Profile URL : https://github.com/torvalds
Status Code : 200
Content-Type: application/json; charset=utf-8
Rate Limit : 58 requests remaining
raise_for_status() and go straight to .json(), you will either get a ValueError when parsing an HTML error page, or worse, you will silently process a JSON error envelope as if it were real data. Always call raise_for_status() before accessing the body. Every time. No exceptions to this rule.response.json() without first checking response.status_code or calling raise_for_status(). The failure mode is invisible during development — the happy path works perfectly — and only surfaces in production when the upstream API is under stress and starts returning 503 responses with JSON error bodies that your code processes as real data, corrupting your database or triggering downstream failures with no clear error trail. Always gate .json() access behind raise_for_status().response.json() for JSON bodies, response.text for raw text, response.content for binary data like images or filesPOST Requests and Sending Data — How You Talk Back to a Server
A GET request fetches data. A POST request sends data and asks the server to do something with it — create a record, trigger an action, authenticate a user, process a payment. The distinction matters because GET requests should be safe to repeat with no side effects. POST requests are not — sending the same POST twice might create two records, charge a card twice, or fire a webhook twice.
There are two common ways to send data with a POST: form-encoded (the data= parameter) or JSON (the json= parameter). This distinction causes more production bugs than almost anything else in Requests. When you use json=, Requests automatically serialises your Python dict to a JSON string, sets Content-Type to application/json, and encodes the body correctly. When you use data= with a dict, it sends the data as HTML form fields — application/x-www-form-urlencoded — which is what browsers send when you submit a form. Most modern REST APIs expect JSON. Sending form data to a JSON API typically results in a 400 Bad Request with a vague error message, because the server is trying to parse form-encoded bytes as JSON and failing.
The subtle trap is data=json.dumps(payload). The body bytes are correct JSON. But the Content-Type header is wrong — Requests does not automatically set it to application/json when you use data=. The server sees JSON bytes arriving with an application/x-www-form-urlencoded header and rejects it. It looks identical when you print(response.request.body) but fails because of a header you cannot see without explicitly printing response.request.headers.
Query parameters are separate from the request body. They appear in the URL after a ? — things like ?state=open&per_page=25. Use the params= argument in Requests to add them. Never concatenate them into URL strings yourself — Requests handles URL encoding correctly, including special characters and spaces, which manual string formatting often gets wrong.
import requests import os # Load credentials from environment — never hardcode tokens in source code github_token = os.environ.get("GITHUB_TOKEN") if not github_token: raise EnvironmentError( "GITHUB_TOKEN environment variable is not set. " "Generate a personal access token at https://github.com/settings/tokens" ) REPO_OWNER = "your-username" REPO_NAME = "your-test-repo" API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/issues" # The payload we are sending — a plain Python dict # Requests will serialise this to JSON and set the Content-Type header automatically issue_payload = { "title": "Bug: Login page crashes on empty password field", "body": ( "Steps to reproduce:\n" "1. Navigate to /login\n" "2. Leave the password field blank\n" "3. Click Submit\n\n" "Expected: Client-side validation error\n" "Actual: 500 Internal Server Error — unhandled NullPointerException in auth middleware" ), "labels": ["bug", "high-priority"] } # Headers set once — GitHub requires Accept and the API version header request_headers = { "Authorization": f"Bearer {github_token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } # json=issue_payload is correct — NOT data=json.dumps(issue_payload) # The difference: json= sets Content-Type to application/json automatically # data= would set Content-Type to application/x-www-form-urlencoded — the server would reject it response = requests.post( url=API_URL, json=issue_payload, headers=request_headers, timeout=(3, 10) ) # 201 Created is the correct success code for resource creation — not 200 # raise_for_status() handles anything that's not 2xx response.raise_for_status() created_issue = response.json() print(f"Issue #{created_issue['number']} created successfully.") print(f"Title : {created_issue['title']}") print(f"URL : {created_issue['html_url']}") print(f"State : {created_issue['state']}") # Demonstrate query parameters — fetch only open issues, 5 per page # params= builds the URL query string: ...?state=open&per_page=5 # Never build this string manually — Requests handles encoding edge cases correctly search_params = {"state": "open", "per_page": 5, "sort": "created", "direction": "desc"} open_issues_response = requests.get( url=API_URL, params=search_params, headers=request_headers, timeout=(3, 10) ) open_issues_response.raise_for_status() open_issues = open_issues_response.json() print(f"\nMost recent open issues ({len(open_issues)} returned):") for issue in open_issues: print(f" #{issue['number']}: {issue['title']}")
Title : Bug: Login page crashes on empty password field
URL : https://github.com/your-username/your-test-repo/issues/42
State : open
Most recent open issues (5 returned):
#42: Bug: Login page crashes on empty password field
#38: Feature: Add dark mode to dashboard
#31: Docs: Update README with Docker setup instructions
#27: Bug: CSV export truncates rows over 1000 records
#19: Chore: Upgrade dependencies to resolve CVE-2024-38821
Sessions, Timeouts and Retries — Writing Production-Grade Code
Here's what most tutorials skip: using bare requests.get() and requests.post() in production service code is an anti-pattern. Not because it's broken — it works fine for one-off scripts and local testing. But because it opens a new TCP connection for every single call. When you're hitting the same API 50 times to paginate through results, or making 10 concurrent calls in a thread pool, those repeated connection setups and TLS handshakes add up to real latency and real resource consumption.
A Session object solves this by maintaining a connection pool. Connections to the same host are reused across requests, which eliminates the per-call handshake overhead and is measurably faster under any real workload.
But connection reuse is not the main reason senior engineers reach for Sessions. The main reason is that Sessions give you a single place to configure everything that applies to all your requests — auth headers, base headers, cookies, retry strategies, TLS settings — and then all of those settings are automatically applied to every request you make through that session. You stop repeating yourself. And you stop the class of bugs where you added the auth header to seven out of eight calls and the eighth one silently fails with a 401.
Timeouts deserve their own paragraph because they are the most commonly omitted configuration and the most reliably catastrophic when missing. requests.get() has no default timeout. It will block the calling thread indefinitely. In a concurrent application — a Flask or FastAPI service running with multiple workers, or any thread pool — one slow API call that takes 90 seconds instead of 500ms will hold its thread for 90 seconds. If you get enough slow calls in parallel, you exhaust your thread pool. Your service appears alive — the process is running, memory looks fine — but it is processing nothing. This is exactly the failure mode from the production incident at the top of this guide.
The fix is always setting timeout=(connect_timeout, read_timeout) as a two-element tuple. The connect timeout is how long to wait for the initial TCP connection to be established. The read timeout is how long to wait between bytes of the response being received. For most APIs, (3, 10) is a reasonable starting point. For large file downloads, increase the read timeout. For fast internal services, tighten it.
For resilience against transient failures, pair a Session with urllib3's HTTPAdapter and Retry strategy. This handles the retry loop you would otherwise write manually, implements correct exponential backoff, and respects the right set of retryable status codes. The key insight: only retry on status codes that indicate transient infrastructure failures — 429, 502, 503, 504. Never retry on 400, 401, 403, 404, or 422 — those are permanent errors that will fail identically on every attempt and retrying them just wastes time and generates noise in logs.
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import os def build_resilient_session( api_token: str, pool_connections: int = 10, pool_maxsize: int = 50, ) -> requests.Session: """ Creates a production-grade requests Session with: - Shared auth headers set once, inherited by all requests - Connection pooling to reuse TCP connections across calls - Automatic retry on transient failures with exponential backoff - Explicit pool size limits to prevent connection exhaustion Args: api_token: Bearer token for Authorization header pool_connections: Number of distinct host connection pools to maintain pool_maxsize: Max simultaneous connections per pool Returns: Configured requests.Session ready for production use """ session = requests.Session() # Headers set here are sent with every request through this session # No need to repeat them on individual calls session.headers.update({ "Authorization": f"Bearer {api_token}", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "TheCodeForge-Client/1.0" }) # Retry strategy: retryable status codes are transient infrastructure failures # Do NOT add 400, 401, 403, 404 — those are permanent and will always fail the same way # backoff_factor=1 produces waits of: 0s, 2s, 4s, 8s between successive retries # (formula: backoff_factor * (2 ** (retry_number - 1))) retry_strategy = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "HEAD", "OPTIONS"], # Only retry idempotent methods by default raise_on_status=False # Let raise_for_status() handle this — do not raise inside retry ) # HTTPAdapter wraps the retry strategy and sets connection pool limits # pool_maxsize prevents connection exhaustion under concurrent load http_adapter = HTTPAdapter( max_retries=retry_strategy, pool_connections=pool_connections, pool_maxsize=pool_maxsize ) # Mount the adapter for both HTTPS and HTTP — always cover both prefixes session.mount("https://", http_adapter) session.mount("http://", http_adapter) return session # --- Usage --- api_token = os.environ.get("WEATHER_API_KEY", "demo_key") BASE_URL = "https://api.open-meteo.com/v1" session = build_resilient_session(api_token=api_token) try: response = session.get( url=f"{BASE_URL}/forecast", params={ "latitude": 51.5074, # London "longitude": -0.1278, "current_weather": True, "wind_speed_unit": "kmh" }, timeout=(3, 10) # Always set timeout even on sessions # Note: timeout is NOT persisted on the session itself — set it per-call ) response.raise_for_status() weather_data = response.json() current = weather_data["current_weather"] print(f"Location : London, UK") print(f"Temperature : {current['temperature']} °C") print(f"Wind Speed : {current['windspeed']} km/h") print(f"Wind Dir : {current['winddirection']}°") print(f"Weather Code: {current['weathercode']}") print(f"\nResponse time: {response.elapsed.total_seconds():.3f}s") except requests.exceptions.Timeout: # Raised when connect_timeout or read_timeout is exceeded # Do not retry here — the retry strategy handles that inside the session print("ERROR: The weather API did not respond in time. Try again in a few seconds.") except requests.exceptions.ConnectionError: # DNS failure, refused connection, or network unreachable print("ERROR: Could not reach the weather API. Check network connectivity.") except requests.exceptions.HTTPError as http_err: # Raised by raise_for_status() for 4xx and 5xx responses that exhausted retries print(f"ERROR: HTTP {http_err.response.status_code} — {http_err}") finally: # Always close the session to release the underlying connection pool # Use as a context manager (with build_resilient_session(...) as session:) to handle this automatically session.close()
Temperature : 14.2 °C
Wind Speed : 18.5 km/h
Wind Dir : 247°
Weather Code: 3
Response time: 0.312s
- Session reuses TCP connections via connection pooling — one TLS handshake per host, not one per request
- Session persists headers and auth across all requests — set once, inherited everywhere, no repetition
- HTTPAdapter + Retry handles transient failures automatically — no manual retry loops, correct exponential backoff
- Timeout must still be set per-call — it is not a session-level setting. Every
requests.get()andrequests.post()call needs its own timeout argument - Bare
requests.get()is for one-off scripts — anything running in a service under concurrent load should use Session with explicit pool limits
requests.get() waits forever by default and a missing timeout is a production outage waiting to happen.requests.get() with timeout is fine — no Session overhead needed for a single callSession() — reuses TCP connections and eliminates repeated header configurationAuthentication Patterns — Basic Auth, Tokens, and OAuth in the Real World
Almost every API that does anything useful requires authentication. Understanding which auth pattern to use, why it exists, and how to implement it correctly is what separates an engineer who can follow an API quickstart from one who can build a production integration that stays secure and maintainable.
Basic Auth is the oldest pattern. Your username and password are combined as 'username:password', base64-encoded, and sent in every request as the Authorization header value. Requests handles this encoding for you when you pass auth=HTTPBasicAuth(username, password) or the equivalent tuple shorthand auth=(username, password). Basic Auth is simple and widely supported. Its weakness is that the password is transmitted with every request — if you're ever on HTTP instead of HTTPS, those credentials are readable by anyone on the network. Only use Basic Auth over HTTPS, and treat it as a legacy pattern for internal tools or simple APIs. Most serious APIs have moved away from it.
Bearer token authentication is the modern standard. You obtain a token once — usually at login or from an API key dashboard — and attach it to every subsequent request as 'Authorization: Bearer your_token_here'. The server validates the token without ever seeing your password again. The Session pattern makes this elegant: set the Authorization header once on the session and every request inherits it automatically. This is the pattern used by GitHub, Stripe, OpenAI, and most REST APIs built in the last several years.
OAuth 2.0 is the framework for apps acting on behalf of users. Instead of handling user passwords, your application redirects the user to the provider's login page, the user authenticates there, and the provider sends your app a token with specific scopes. Your app never sees the password. The requests-oauthlib library extends Requests with full OAuth 2.0 flow support. This is what you need for 'Sign in with Google', 'Connect with GitHub', Spotify API access, or any integration where users authorise your app to act on their accounts.
API key authentication varies more than the others. Some APIs want the key as a query parameter (?api_key=...). Others want it as a custom header (X-API-Key: ...). A few use the standard Authorization header with a custom scheme (ApiKey your_key_here). Always check the API documentation. As a general principle, prefer header-based API keys over query parameter keys — query parameters appear in server access logs, CDN logs, and browser history, which makes them a persistent credential exposure risk that is hard to detect and remediate.
import requests from requests.auth import HTTPBasicAuth import os # ─── PATTERN 1: Basic Auth ──────────────────────────────────────────────────── # Requests base64-encodes "username:password" and sets the Authorization header # Use HTTPBasicAuth explicitly for clarity — the tuple shorthand also works: # auth=("demo_user", "secret123") is identical to HTTPBasicAuth(...) print("=== Pattern 1: Basic Auth ===") basic_response = requests.get( url="https://httpbin.org/basic-auth/demo_user/secret123", auth=HTTPBasicAuth("demo_user", "secret123"), timeout=(3, 10) ) basic_response.raise_for_status() result = basic_response.json() print(f"Authenticated : {result['authenticated']}") print(f"User : {result['user']}") print(f"Auth header : {basic_response.request.headers.get('Authorization')}") # Output will show: Basic ZGVtb191c2VyOnNlY3JldDEyMw== (base64 of demo_user:secret123) # ─── PATTERN 2: Bearer Token Auth via Session ───────────────────────────────── # Set the token once on the session — every subsequent call inherits it # No need to pass headers= on every individual request print("\n=== Pattern 2: Bearer Token via Session ===") api_token = os.environ.get("MY_API_TOKEN", "mock_token_for_demo_abc123") token_session = requests.Session() token_session.headers.update({ "Authorization": f"Bearer {api_token}", "Accept": "application/json" }) try: token_response = token_session.get( url="https://httpbin.org/bearer", timeout=(3, 10) ) token_response.raise_for_status() result = token_response.json() print(f"Authenticated : {result['authenticated']}") print(f"Token received: {result['token']}") finally: token_session.close() # Release the connection pool # ─── PATTERN 3: API Key as Custom Header ────────────────────────────────────── # Some APIs (OpenAI uses Authorization: Bearer, Stripe uses Authorization: Bearer, # but others use custom header names — always check the docs) # Header-based keys are safer than query param keys — they stay out of server logs print("\n=== Pattern 3: API Key as Custom Header ===") api_key = os.environ.get("SERVICE_API_KEY", "sk-demo-key-for-testing-9999") header_key_response = requests.get( url="https://httpbin.org/headers", headers={ "X-API-Key": api_key, "Accept": "application/json" }, timeout=(3, 10) ) header_key_response.raise_for_status() returned_headers = header_key_response.json()["headers"] print(f"API Key received: {returned_headers.get('X-Api-Key', 'not found')}") print("Key delivered via header — does not appear in URL or server access logs") # ─── PATTERN 4: API Key as Query Parameter (avoid if header-based is available) ─ # Some older APIs only support query param auth — use it only if you have no choice # Never log the full URL when using query-param API keys print("\n=== Pattern 4: API Key as Query Param (legacy — avoid when possible) ===") query_key_response = requests.get( url="https://httpbin.org/get", params={"api_key": api_key, "other_param": "value"}, timeout=(3, 10) ) query_key_response.raise_for_status() print(f"Full URL (key visible): {query_key_response.url}") print("WARNING: This URL — and the key in it — will appear in every server access log")
Authenticated : True
User : demo_user
Auth header : Basic ZGVtb191c2VyOnNlY3JldDEyMw==
=== Pattern 2: Bearer Token via Session ===
Authenticated : True
Token received: mock_token_for_demo_abc123
=== Pattern 3: API Key as Custom Header ===
API Key received: sk-demo-key-for-testing-9999
Key delivered via header — does not appear in URL or server access logs
=== Pattern 4: API Key as Query Param (legacy — avoid when possible) ===
Full URL (key visible): https://httpbin.org/get?api_key=sk-demo-key-for-testing-9999&other_param=value
WARNING: This URL — and the key in it — will appear in every server access log
os.environ.get() or from a .env file using python-dotenv. Add .env to your .gitignore before your first commit, not after. If a token was ever hardcoded and committed — even for one commit, even in a 'private' repo — rotate it immediately. git history is permanent and full-history searches for credentials in leaked repos are automated.os.environ.get() or python-dotenv, and add .env to .gitignore before your first commit.| Feature / Aspect | requests.get/post (bare calls) | requests.Session |
|---|---|---|
| TCP connection per request | New connection opened and closed every time — one TLS handshake per call | Reuses existing pooled connections — one handshake per host, amortised across many requests |
| Shared headers and auth | Must be passed explicitly on every individual call — easy to miss one | Set once on the session, inherited automatically by every request |
| Cookie persistence | Cookies are not carried between calls — each is a stateless transaction | Cookies are automatically stored and sent on subsequent calls to the same host |
| Retry logic | Must write your own retry loop — usually done incorrectly or not at all | Mount HTTPAdapter with Retry strategy — handles backoff, status codes, and method filtering automatically |
| Connection pool limits | No pool — each call gets its own file descriptor and port | pool_connections and pool_maxsize on HTTPAdapter cap resource usage explicitly |
| Best use case | One-off scripts, local testing, single API calls with no follow-up requests | Production services, multi-step workflows, any code making 3+ requests to the same host |
| Performance under load | Degrades linearly — more requests means more handshakes means more latency | Scales cleanly — connection pooling absorbs repeated calls to the same host |
🎯 Key Takeaways
- Always call
response.raise_for_status()before accessing response data — Requests does not raise exceptions on 404 or 500 by default. Skipping this call means your code will silently process error responses as valid data, which causes downstream bugs that surface far from the original API call and are hard to trace. - Use json=payload, not data=json.dumps(payload) — the json= parameter handles serialisation and sets the Content-Type header in a single argument. The data= version sends the right body bytes but the wrong Content-Type, which causes most REST APIs to return a 400 with no useful diagnostic information.
- Use requests.
Session()for any production code making multiple requests to the same host — it reuses TCP connections via connection pooling, persists auth headers and cookies across all calls, and is the correct foundation for mounting retry strategies. Barerequests.get()is for one-off scripts. - Timeouts are non-negotiable in production —
requests.get()waits forever by default. Always set timeout=(3, 10) or similar values appropriate to your upstream, and always handle requests.exceptions.Timeout explicitly. A single missing timeout in a concurrent service can cause a complete outage when an upstream degrades.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat's the difference between using
requests.get()directly and using a requests.Session(), and when would you choose one over the other in a production system?Mid-levelReveal - QHow would you implement retry logic for a flaky third-party API in Python, and what HTTP status codes should trigger a retry versus an immediate failure?Mid-levelReveal
- QIf a POST request to a REST API returns a 200 OK but the body contains an error message like {'status': 'error', 'message': 'Invalid token'} — does
raise_for_status()catch that? How would you handle it?SeniorReveal
Frequently Asked Questions
How do I send JSON data in a POST request using Python Requests?
Pass your Python dictionary to the json= parameter: requests.post(url, json=your_dict). Requests serialises the dictionary to a JSON string and sets Content-Type: application/json automatically. Do not use data=json.dumps(your_dict) — that sends the correct JSON bytes but sets Content-Type to application/x-www-form-urlencoded, which causes most REST APIs to reject the request with a 400. The json= parameter is one argument that eliminates an entire class of Content-Type mismatch bugs.
What is the difference between requests.get() and requests.Session().get()?
Both make a GET request, but Session().get() reuses the underlying TCP connection across multiple calls through connection pooling — no repeated TLS handshakes, which is meaningfully faster under real workloads. Sessions also automatically persist headers, cookies, and authentication settings across all requests, so you configure auth once and stop repeating it on every call. For a single one-off request in a script, bare requests.get() is perfectly fine. For production code making multiple requests, or any code running in a service under concurrent load, always use a Session.
Why does my Python Requests code hang and never finish?
You almost certainly have not set a timeout. requests.get() and requests.post() wait indefinitely by default — there is no built-in limit. If the server is slow, overloaded, or silently dropping connections, your script blocks forever. Fix it by adding timeout=(connect_timeout, read_timeout) to every request — for example, timeout=(3, 10). This tells Requests to raise requests.exceptions.Timeout if the connection takes more than 3 seconds to establish or the response body takes more than 10 seconds to arrive. Catch that exception explicitly and handle it — do not let it propagate silently.
How do I handle rate limiting (HTTP 429) gracefully with Python Requests?
Mount an HTTPAdapter with a Retry strategy on your Session, configured with status_forcelist=[429] and backoff_factor=1. This creates automatic exponential backoff — 2 seconds before the first retry, 4 before the second, 8 before the third — without you writing a retry loop. Additionally, check the Retry-After header in the 429 response body. Many APIs include the exact number of seconds to wait before retrying. If present, honour it — a well-behaved client respects Retry-After rather than hammering with exponential backoff, which shows up in the API provider's abuse detection systems and can get your key rate-limited more aggressively.
Is it safe to use verify=False to disable SSL certificate verification?
Never in production. Setting verify=False disables all TLS certificate validation — your code will accept any certificate from any server, including certificates presented by a man-in-the-middle attacker on the same network. It also suppresses the urllib3 InsecureRequestWarning, which means the vulnerability becomes invisible in logs. For local development with a self-signed certificate, use verify='/path/to/ca-cert.pem' to point Requests at the specific CA certificate. For production, leave verify=True (the default) and ensure your system's CA bundle is current. If an internal service uses a private CA, distribute the CA certificate and reference it explicitly.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.