aio-libs/aiohttp · error · ValueError

charset must not be in content_type argument

Error message

charset must not be in content_type argument

What it means

Raised by the Response constructor when the `content_type` kwarg string itself contains the substring 'charset' (e.g. 'text/html; charset=utf-8'). aiohttp manages charset separately via the `charset` kwarg and the `; charset=...` suffix is appended internally; embedding it in content_type would produce a malformed or duplicated header.

Solutions

  1. Split the MIME type and charset: `Response(content_type='text/html', charset='utf-8')`.
  2. Use `text=` which auto-derives `text/plain; charset=utf-8` (override content_type separately if needed).
  3. If the string is user-supplied, parse out charset: split on ';', strip the charset param, pass the rest.
  4. Sanitize config values by storing MIME type and charset as separate fields.

Example fix

// before
return Response(text=html, content_type='text/html; charset=utf-8')

// after
return Response(text=html, content_type='text/html', charset='utf-8')
Defensive patterns

Strategy: validation

Validate before calling

def split_content_type(raw: str):
    if ';' in raw:
        ctype, _, params = raw.partition(';')
        charset = None
        for p in params.split(';'):
            p = p.strip()
            if p.lower().startswith('charset='):
                charset = p.split('=', 1)[1]
        return ctype.strip(), charset
    return raw, None

ctype, cs = split_content_type(raw)
return Response(text=t, content_type=ctype, charset=cs)

Type guard

def content_type_has_no_charset(ctype: str) -> bool:
    return 'charset' not in ctype.lower()

Prevention

When it happens

Trigger: Passing `Response(content_type='application/json; charset=utf-8')`, or copying a full Content-Type string from a config file/header straight into the `content_type=` argument.

Common situations: Copy-pasting Content-Type header values (which legitimately contain charset) into the content_type parameter that expects only the MIME type. Configuration files that store the full header. Naive porting of Flask/Django patterns where the full header string is accepted.

Related errors


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

Appendix: source

Thrown at aiohttp/web_response.py:563

        status: int = 200,
        reason: str | None = None,
        text: str | None = None,
        headers: LooseHeaders | None = None,
        content_type: str | None = None,
        charset: str | None = None,
        zlib_executor_size: int = MAX_SYNC_CHUNK_SIZE,
        zlib_executor: Executor | None = None,
    ) -> None:
        if body is not None and text is not None:
            raise ValueError("body and text are not allowed together")

        if headers is None:
            real_headers: CIMultiDict[str] = CIMultiDict()
        else:
            real_headers = CIMultiDict(headers)

        if content_type is not None and "charset" in content_type:
            raise ValueError("charset must not be in content_type argument")

        if text is not None:
            if hdrs.CONTENT_TYPE in real_headers:
                if content_type or charset:
                    raise ValueError(
                        "passing both Content-Type header and "
                        "content_type or charset params "
                        "is forbidden"
                    )
            else:
                # fast path for filling headers
                if not isinstance(text, str):
                    raise TypeError("text argument must be str (%r)" % type(text))
                if content_type is None:
                    content_type = "text/plain"
                if charset is None:
                    charset = "utf-8"
                real_headers[hdrs.CONTENT_TYPE] = content_type + "; charset=" + charset

View on GitHub (pinned to d041d4d0fd)