aio-libs/aiohttp · error · ValueError

base_url must have a trailing '/'

Error message

base_url must have a trailing '/'

What it means

Raised by ClientSession.__init__ when base_url's path does not end with '/'. aiohttp resolves relative request URLs against base_url, so a missing trailing slash would silently drop the last path segment (e.g. 'http://x/api' + 'foo' resolves to 'http://x/foo', not 'http://x/api/foo'). The check is intentional to prevent wrong request URLs.

Solutions

  1. Add a trailing '/' to base_url: ClientSession(base_url='http://example.com/api/').
  2. Construct with an aiohttp.URL: ClientSession(base_url=URL('http://example.com/api/')).
  3. If the value comes from config, normalize at load time: base_url = cfg.rstrip('/') + '/'.

Example fix

// before
session = ClientSession(base_url='http://example.com/api')
// after
session = ClientSession(base_url='http://example.com/api/')
Defensive patterns

Strategy: validation

Validate before calling

from aiohttp import URL

def normalize_base_url(raw: str) -> URL:
    u = URL(raw)
    if not u.path.endswith('/'):
        u = u.with_path(u.path + '/')
    return u

# at session creation
ClientSession(base_url=normalize_base_url(cfg['base_url']))

Type guard

from aiohttp import URL

def has_trailing_slash(u: URL) -> bool:
    return u.path.endswith('/')

Try / catch

try:
    session = ClientSession(base_url=raw)
except ValueError as e:
    if 'trailing' in str(e):
        raw = raw.rstrip('/') + '/'
        session = ClientSession(base_url=raw)
    else:
        raise

Prevention

When it happens

Trigger: Calling ClientSession(base_url='http://example.com/api') (no trailing '/'). Passing base_url as a string without a trailing slash; the URL is parsed and self._base_url.path.endswith('/') fails.

Common situations: Copying an API base URL from a browser bar or docs that omits the trailing slash. Migrating from requests/httpx where trailing slashes are not enforced. Reading base_url from an env var that was trimmed.

Related errors


AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11). Data as JSON: /api/errors/15fb1e350e1b4caf. Report an issue: GitHub.

Appendix: source

Thrown at aiohttp/client.py:337

        max_line_size: int = 8190,
        max_field_size: int = 8190,
        max_headers: int = 128,
        fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
        middlewares: Sequence[ClientMiddlewareType] = (),
        ssl_shutdown_timeout: _SENTINEL | None | float = sentinel,
    ) -> None:
        # We initialise _connector to None immediately, as it's referenced in __del__()
        # and could cause issues if an exception occurs during initialisation.
        self._connector: BaseConnector | None = None
        if base_url is None or isinstance(base_url, URL):
            self._base_url: URL | None = base_url
            self._base_url_origin = None if base_url is None else base_url.origin()
        else:
            self._base_url = URL(base_url)
            self._base_url_origin = self._base_url.origin()
            assert self._base_url.absolute, "Only absolute URLs are supported"
        if self._base_url is not None and not self._base_url.path.endswith("/"):
            raise ValueError("base_url must have a trailing '/'")

        if not isinstance(ssl, SSL_ALLOWED_TYPES):
            raise TypeError(
                "ssl should be SSLContext, Fingerprint, or bool, "
                f"got {ssl!r} instead."
            )

        loop = asyncio.get_running_loop()

        if timeout is sentinel or timeout is None:
            timeout = ClientTimeout()
        if not isinstance(timeout, ClientTimeout):
            raise ValueError(
                f"timeout parameter cannot be of {type(timeout)} type, "
                "please use 'timeout=ClientTimeout(...)'",
            )
        self._timeout = timeout

View on GitHub (pinned to d041d4d0fd)