Repository navigation
RFC: Adding inline type annotations to Requests #7271
Description
Activity
- pinned this issue
on Mar 18, 2026 Thanks for the efforts of adding inline type annotations.
Here are some issues I've encountered after installing
requestsfrom itsinline_types_rfcgit branch and removingtypes-requestsfrom my Python environment while using both mypy and ty in my project. I'm including suff that's already listed in the known issues above.-
Response.status_codeis annotated asint | None, whereas typeshed simply annotates it asint. WithNoneincluded in a type union, this introduces several typing issues in trivial code snippets:error[unsupported-operator] Operator
>=is not supported between objects of typeint | NoneandLiteral[400]res = session.get(...) if 400 <= res.status_code < 500: ...
Does it make sense annotating it as
int | Nonejust because it's initialized asNone? -
Session.request()and the various HTTP-verb methods (get, post, head, options, delete, put, patch) all differ from typeshed's definitions. Since my project extendsSession.request()and the various methods, this causes compatibility issues, but I hadn't had a deeper look at the exact changes yetrequests/src/requests/sessions.py
Lines 562 to 580 in 781caba
def request( self, method: str, url: str, params: ParamsType = None, data: DataType = None, headers: Mapping[str, str | bytes] | None = None, cookies: RequestsCookieJar | CookieJar | dict[str, str] | None = None, files: FilesType = None, auth: AuthType = None, timeout: TimeoutType = None, allow_redirects: bool = True, proxies: dict[str, str] | None = None, hooks: HooksType = None, stream: bool | None = None, verify: VerifyType | None = None, cert: CertType = None, json: JsonType = None, ) -> Response: - https://github.com/python/typeshed/blob/1bf663e7491e599f6d0ad8fc9338bc42214176ed/stubs/requests/requests/sessions.pyi#L138-L156
-
Session.headersis not aMutableMapping(unlike typeshed), leading to typing issues when trying to set the attribute directly (e.g.session.headers = {}) -
Response.iter_linesstr/bytes overload issue:requests/src/requests/models.py
Lines 959 to 979 in 781caba
@overload def iter_lines( self, chunk_size: int = ITER_CHUNK_SIZE, decode_unicode: Literal[False] = False, delimiter: bytes | None = None, ) -> Iterator[bytes]: ... @overload def iter_lines( self, chunk_size: int = ITER_CHUNK_SIZE, *, decode_unicode: Literal[True], delimiter: str | bytes | None = None, ) -> Iterator[str | bytes]: ... def iter_lines( self, chunk_size: int = ITER_CHUNK_SIZE, decode_unicode: bool = False, delimiter: str | bytes | None = None, ) -> Iterator[str | bytes]: - https://github.com/python/typeshed/blob/1bf663e7491e599f6d0ad8fc9338bc42214176ed/stubs/requests/requests/models.pyi#L148-L150
error[invalid-assignment] Object of type
filter[str | bytes]is not assignable toIterator[str]lines: Iterator[str] lines = iter(filter(bool, res.iter_lines(decode_unicode=True)))
-
requests.compat.chardettyping issues: we have a compatibility import for charset_normalizer, based on what requests imports, like this:try: from requests.compat import chardet as charset_normalizer # type: ignore except ImportError: # pragma: no cover import charset_normalizer detect_encoding = charset_normalizer.detect
This now results in ty emitting this error:
error[unresolved-attribute] Attribute
detectis not defined onNonein unionModuleType | None
Reacted by Edgar Ramírez Mondragón-
Thanks for taking the time to look through the types, @bastimeyer!
Response.status_code is annotated as int | None, whereas typeshed simply annotates it as int. With None included in a type union, this introduces several typing issues in trivial code snippets:
That's a fair callout. Requests has this issue with
Request,PreparedRequest, andResponse. You'll see _ValidatedRequest as an attempt to model the state of a PreparedRequest that was actually initialized byprepare()instead of manually. The Response case has this too, but it looks like that wasn't encapsulated as well as it could. A bareResponse()will beNone, and mocking tooling does that. Whether that's intended usage, is another question. We may need another TypeGuard to constrain a narrow version of Response returned from the APIs, or take the approach Typeshed did and suppress the errors on a bare Response.Session.request() and the various HTTP-verb methods (get, post, head, options, delete, put, patch) all differ from typeshed's definitions.
I'm assuming this is typeshed's practice of materializing all kwargs directly on every function signature? There's been some discussion about that already here. Once we solve the Unpack layering, I think this will go away. Let me know if I'm missing something though.
Session.headers is not a MutableMapping (unlike typeshed), leading to typing issues when trying to set the attribute directly (e.g. session.headers = {})
This one is interesting but not surprising. I originally had
MutableMappinghere but had to constrain it toCaseInsensitiveDict. That's both what it's always been documented as being, and a runtime requirement for multiple code paths. You'll end up with inaccurate comparisons (and potentially incorrect headers) if you're using a bare dict. I think we want to keep this one so we're not subtly misleading users. It looks like someone got (reasonably) confused, and changed this incorrectly in typeshed.Response.iter_lines str/bytes overload issue:
This one is actually correct. Your response with
decode_unicode=Trueis not guaranteed to be Iterable[str]. If Requests cannot determine the encoding, it falls back to bytes. Typing it strictly as Iterably[str] is not true at runtime and is a vector for breakages if your code assumes that.requests.compat.chardet typing issues: we have a compatibility import for charset_normalizer, based on what requests imports, like this:
This one is also subtle. If neither
charset_normalizerorchardetare installed at runtime, you will get backNonefrom your import. I don't think you can actually hit your ImportError fallback in this case? There isn't a released version of Requests that runs on Python 3.9+ that doesn't have the import available.That said, I don't think Requests can actually run if neither is present, so I think this just becomes a runtime error in most cases. I'm open to constraining it to module type, it's just going to be slightly incorrect.Requests does round trip successfully without either character detection library so this is a possibility, although I'd consider it obscure/not generally supported.Reacted by Ian Stapleton Cordasco- added 4 commits that reference this issue
on Mar 27, 2026 I've moved the PR from Draft to Ready for Review. While there may be minor refactors in the coming week or two, I think the changes are largely done. It's safe to assume time for comment will be closing in the coming weeks, please take a look now if you intend to do so.
A pre-release (2.34.0.dev1) is now available on PyPI for easier install.
Reacted by Matheus Santos and Seth LarsonReacted by Edgar Ramírez MondragónHey Nate, thanks for publishing that pre-release!
The class-level attribute annotations on
Responseare causing issues for downstream projects that subclass it as a dataclass or similar. Other classes inmodels.pysurely have the same problem. It does seem that enabling forward references is the culprit downstream, so I wonder if this something that should be addressed here.For example, in
requests-cache:@define(auto_attribs=False, repr=False, slots=False) class BaseResponse(Response): """Wrapper class for responses returned by :py:class:`.CachedSession`. This mainly exists to provide type hints for extra cache-related attributes that are added to non-cached responses. """
FWIW moving the annotations to the attribute assignments inside
__init__seems to fix the problem.
Here's a MRE
# /// script # dependencies = [ # "requests==2.34.0.dev1", # "requests-cache==1.3.1", # ] # /// import requests_cache session = requests_cache.CachedSession('demo_cache') for i in range(60): session.get('https://httpbin.org/delay/1')
Traceback
uv run --script main.pyInstalled 11 packages in 10ms Traceback (most recent call last): File "/Users/me/Code/repro/requests-234/main.py", line 12, in <module> session.get('https://httpbin.org/delay/1') ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 133, in get return self.request('GET', url, params=params, **kwargs) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 189, in request return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests/sessions.py", line 651, in request resp = self.send(prep, **send_kwargs) File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 224, in send cached_response = self.cache.get_response(actions.cache_key) File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/base.py", line 69, in get_response response = self.responses.get(key) File "<frozen _collections_abc>", line 795, in get File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/sqlite.py", line 338, in __getitem__ return self.deserialize(key, row[0]) ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/base.py", line 354, in deserialize obj = self.serializer.loads(value) File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/serializers/pipeline.py", line 72, in loads value = step(value) File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/functools.py", line 1069, in __call__ return self._dispatch(args[0].__class__).__get__(self._obj, self._cls)(*args, **kwargs) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/serializers/cattrs.py", line 88, in _ return _encode_content(self.converter.structure(value, cl=CachedResponse)) ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/converters.py", line 591, in structure return self._structure_func.dispatch(cl)(obj, cl) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/dispatch.py", line 133, in dispatch_without_caching res = self._function_dispatch.dispatch(typ) File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/dispatch.py", line 76, in dispatch return handler(typ) File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/converters.py", line 1308, in gen_structure_attrs_fromdict resolve_types(origin or cl) ~~~~~~~~~~~~~^^^^^^^^^^^^^^ File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/attr/_funcs.py", line 487, in resolve_types hints = typing.get_type_hints(cls, **kwargs) File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 2391, in get_type_hints value = _eval_type(value, base_globals, base_locals, (), format=format, owner=obj, prefer_fwd_module=True) File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 461, in _eval_type return evaluate_forward_ref(t, globals=globalns, locals=localns, type_params=type_params, owner=owner, _recursive_guard=recursive_guard, format=format) File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 1000, in evaluate_forward_ref value = forward_ref.evaluate(globals=globals, locals=locals, type_params=type_params, owner=owner, format=format) File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/annotationlib.py", line 201, in evaluate raise NameError(_NAME_ERROR_MSG.format(name=arg), name=arg) NameError: name 'RequestsCookieJar' is not defined
Thanks for the repro, @edgarrmondragon. This is an unfortunately messy side-effect I was not aware of. Presumably this would apply to most dataclass style libraries beyond
attrs?I don't have a strong reason why these were added at class-level other than consistency. Not all attributes can be moved to
__init__. Some likeResponse.connectionorRequestsCookieJar._policyaren't actually set in the__init__(or don't have an explicit__init__). I tried in this commit but these two top-level attributes are stuck where they are.FWIW moving the annotations to the attribute assignments inside init seems to fix the problem.
Given the above, I'm curious how you aren't hitting errors for
HTTPAdapterwithconnection. Do you have an example of what you tried?Reacted by Edgar Ramírez MondragónGiven the above, I'm curious how you aren't hitting errors for
HTTPAdapterwithconnection. Do you have an example of what you tried?If I move
RequestsCookieJaroutside of theTYPE_CHECKINGblock, then I do get a similar error forHTTPAdapterSome like
Response.connectionorRequestsCookieJar._policyaren't actually set in the__init__(or don't have an explicit__init__).I gave it a try locally and it seems you can still annotate it within
__init__:class Response: def __init__(self, ...) -> None: self.connection: HTTPAdapter
I created a PR with the small diff I tried out locally, which fixed my requests-cache example above:
5 remaining items
I've noticed some issues with the current definition of
ParamsType. I haven't followed this thread since my last post after which I fixed all the issues I ran into, so I'm not sure if this has been brought to attention yet.ParamsTypecurrently definesMapping[_ParamsMappingKeyType, _ParamsMappingValueType]as one of the union items, which is a problem, because the key type is invariant inMapping.The first example works, while the second one results in typing issues (checked via ty/mypy):
import requests requests.get("http://localhost", params={"foo": "bar"})
import requests params={"foo": "bar"} requests.get("http://localhost", params=params)
$ ty check foo.py error[invalid-argument-type]: Argument to function `get` is incorrect --> foo.py:3:34 | 3 | requests.get("http://localhost", params=params) | ^^^^^^^^^^^^^ Expected `Mapping[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None] | Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]] | str | bytes | None`, found `dict[str, str]` | info: type `dict[str, str]` is not assignable to any element of the union `Mapping[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None] | Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]] | str | bytes | None` info: ├── element `bytes` of union `str | bytes | int | float` is not assignable to `str` info: ├── type `dict[str, str]` is not assignable to protocol `Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]` info: │ └── protocol member `__iter__` is incompatible info: │ └── incompatible return types: `Iterator[str]` is not assignable to `Iterator[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]` info: │ └── protocol `Iterator[str]` is not assignable to protocol `Iterator[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]` info: │ └── protocol member `__next__` is incompatible info: │ └── incompatible return types: `str` is not assignable to `tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]` info: └── ... omitted 3 union elements without additional contextTypeshed defines it with a
SupportsItemsprotocol, similar to yours:- https://github.com/python/typeshed/blob/148b3c2d6b194bf8268e6455b22121532d65e2df/stubs/requests/requests/sessions.pyi#L97
- https://github.com/python/typeshed/blob/148b3c2d6b194bf8268e6455b22121532d65e2df/stdlib/_typeshed/__init__.pyi#L164-L165
requests/src/requests/_types.py
Lines 30 to 32 in dc9dbdf
@runtime_checkable class SupportsItems(Protocol): def items(self) -> Iterable[tuple[Any, Any]]: ...
Great find, @bastimeyer! I've put up #7426 which should match more closely to what typeshed is doing and it brings the API closer to our
Iterableinputs. Let me know if you think anything else is missing, otherwise we'll plan to merge that change.Let me know if you think anything else is missing
The
SupportsItemsProtocol changes in #7426 seem fine. At least they fix my issue when installing from that PR branch. Thanks.But what I just noticed is, why is there a dedicated
GetKwargsthat's excluding theparamskeyword from GET requests? GET requests should also allowparams, so query strings can be built from a mapping.RequestKwargsincludes it, but it's explicitly not used forget().requests/src/requests/_types.py
Lines 158 to 167 in dc9dbdf
class RequestKwargs(BaseRequestKwargs, total=False): """kwargs for request(), options(), head(), delete().""" params: ParamsType data: DataType json: JsonType class GetKwargs(BaseRequestKwargs, total=False): data: DataType json: JsonType - Reacted by Sebastian Meyer
- added a commit that references this issue
on May 11, 2026 Adding a quick comment here to demarcate pre and post release of 2.34.0. Anyone arriving here after this comment, please provide a succinct description of what your input is, the affected APIs, and what you think typing should be. We'll review those on a case-by-case basis.
Known Issues
With
Response.reasonbeing explicitly initialized asNoneand typed accordingly as union ofstr | Nonethis breaks type checkers that infer their types mainly from the source likepyright. This happens in our code base where we have custom exceptions we raise and that get initialized with the reason as astr.Interestingly with
types-requestsinstalledmypy,ty, andpyright are fine with us simply passingresponse.reasoninto our custom exception. Onlypyright` complains abouterror: Argument of type "str | None" cannot be assigned to parameter "response_reason" of type "str" in function "__init__"Are you planning to address this or should we handle this?
I also noticed that in the
Response.raise_for_statusmethod there's no check forNonesuch that thereasonvariable in thehttp_error_msgcould be printed as empty.Thanks for the report, @gothicVI. Reason is inherently optional (even at the HTTP Protocol level) and as you noted initialized as
None. That said, we've smoothed this out for otherResponseattributes like status_code where it's almost neverNone. Let me think about this one for a bit today and we'll make a decision on if we clean this up in 2.34.1. Initial testing with urllib3 is showing they are normalizing missing values to''which is the primary path Requests users are going through.- unpinned this issue
on Jun 5, 2026 Closing this out since we haven't had any notable feedback in the last few weeks. Anything new can be raised as its own issue. Thanks again everyone who gave feedback and help with reviewing!
Reacted by Seth Larson, Edgar Ramírez Mondragón and Richard Si


Important
Before reporting new issues, be sure to check this comment to see if your problem is already being addressed.
Motivation
Requests has notably been without inline type annotations since type hints entered the Python ecosystem. We've had aspirations of bringing Requests up to par with community standards for a few years but have been unable to properly express the typing contract. This change doesn't solve all of those gaps, but does make progress towards that goal.
The maintainers of typeshed have provided an incredibly useful alternative for Requests typing for quite a while. There are some limitations of type stubs that can't fully express the Requests API though. Cases like
iter_contenthaving different return typing based on input, or PreparedRequests needing to be state checked can't be easily expressed. Inline annotations let us own the type contract alongside the code and should help drive better development decisions in the future.For most use cases, the public API should be accurately typed. There are some internals that are currently suppressed because they need discussion, but the package passes pyright strict mode without errors. We've flagged where the sharp edges are and can refine those over time.
The main goal is to start this migration with community input to make sure we're covering all usage. The "magic" of Requests is that it's very flexible, which also means people use it in creative ways the maintainers don't always anticipate. We want to identify those edge cases early in this process before the types ship in a release.
What's changed
src/requests/is annotated_types.pymodule for shared type aliases and Protocolsfrom __future__ import annotationsapplied everywhereDesign philosophy
Types should describe the code, not change behavior. This was the primary goal in this process, not adding new constraints.
Where typing forced a choice between runtime strictness and backward compatibility, we chose backward compatibility. That's the lens I'd view this through when things may differ from expectations.
How to test
Install from the branch:
or
Then:
What feedback we're looking for
Important
We want feedback from people who actively maintain projects that depend on Requests or use it heavily. Please share your real experience testing this against your code.
Comments that are clearly AI-generated will be hidden or removed. This is a library written "for Humans". The conversation is between maintainers and users, not a prompt.
If you do find issues, we want to hear about it. Specifically:
We're not looking for style opinions on the annotations themselves or nitpicks on suppression comments. Those are tracked on the PR.
Known limitations
str | bytesURL handling: Requests has historically accepted bothstrandbytesfor URLs. The typing currently declaresstronly. This is a known gap we're still working through.type: ignorecomments exist because urllib3's type stubs don't fully cover all the methods we use.RequestsCookieJarandMutableMapping: The RequestsCookieJar's conformance toMutableMappinghas some known mismatches (extended method signatures,__iter__yieldingCookieobjects instead of strings) that date back to when the class was first written against the Python 2.6 ABC contract. The current types don't fix that, but should be clearer about what the class does. Future work may be able to refine this further.For more details and rationale, see the PR.