Skip to content
techousePublic

About

A query string encoding and decoding library for Python. Ported from qs for JavaScript.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

14 stars

Watchers

1 watching

Forks

qs-codec

qs-codec

A query string encoding and decoding library for Python.

Ported from qs for JavaScript.

PyPI version PyPI downloads PyPI status Python version support PyPy support status PyPI format Test Status CodeQL Status Publish Status Docs Status Code Coverage Codacy Quality OpenSSF Best Practices Code style Black flake8 Status typing mypy linting pylint imports isort Security Status License Contributor Covenant GitHub Sponsors GitHub Repo stars

Highlights

  • Nested dictionaries & lists: foo[bar][baz]=qux ⇄ {'foo': {'bar': {'baz': 'qux'}}}.
  • Multiple list formats: INDICES (a[0]=x), BRACKETS (a[]=x), REPEAT (a=x&a=y), COMMA (a=x,y) with optional comma round-trip.
  • Dot-notation: parse/encode keys like a.b=c as nested; option to encode dots in keys when using dot notation.
  • Charset handling: UTF-8 (default) and Latin-1; optional charset sentinel (utf8=✓) to auto-detect encoding.
  • Pluggable hooks: custom encoder/decoder callables; options to sort keys, filter output, and control percent-encoding (keys-only, values-only).
  • Nulls & empties: strict_null_handling and skip_nulls; support for empty lists/arrays when desired.
  • Dates: serialize_date for ISO 8601 or custom (e.g., UNIX timestamp).
  • Safety limits: configurable decode depth and encode max depth, parameter limit, and list element limit; optional strict-depth errors; duplicate-key strategies (combine/first/last).
  • Extras: numeric entity decoding (e.g. ☺ → ☺), alternate delimiters/regex, and query-prefix helpers.

Compatibility

  • CPython 3.8–3.15 (default tox envs).
  • PyPy 3.8–3.12 (run tox -e pypy3.8 through tox -e pypy3.12 locally; CI mirrors this matrix).

Usage

A simple usage example:

import qs_codec as qs

# Encoding
assert qs.encode({'a': 'b'}) == 'a=b'

# Decoding
assert qs.decode('a=b') == {'a': 'b'}

Compared with urllib.parse

The standard library's urlencode, parse_qs, and parse_qsl are designed for conventional flat application/x-www-form-urlencoded data. Use qs_codec when the query represents nested dictionaries or lists, must interoperate with Node qs, or needs configurable list, duplicate, null, or resource-limit behavior.

urlencode can expand a flat sequence into repeated keys with doseq=True, which corresponds to ListFormat.REPEAT. It does not recursively encode nested mappings; qs.encode uses bracket or dot paths instead:

from urllib.parse import urlencode

import qs_codec as qs

assert urlencode({'tags': ['a', 'b']}, doseq=True) == 'tags=a&tags=b'
assert qs.encode(
    {'tags': ['a', 'b']},
    qs.EncodeOptions(list_format=qs.ListFormat.REPEAT),
) == 'tags=a&tags=b'
assert qs.encode(
    {'filter': {'name': 'Jane'}},
) == 'filter%5Bname%5D=Jane'

The encoding defaults also differ: urlencode emits spaces as + and uses Python scalar spellings such as True and None; qs.encode uses %20, lowercase booleans, and an empty value for None by default.

On decode, parse_qs returns a dictionary whose values are always lists, while parse_qsl returns an ordered list of name/value pairs and preserves interleaved duplicate keys. Both treat bracket expressions as literal key names, drop blank values unless keep_blank_values=True, and collapse a name-only token and an explicit empty value to the same empty string. qs.decode normally returns a scalar for one value, reconstructs bracket paths, and can preserve that null distinction:

from urllib.parse import parse_qs, parse_qsl

import qs_codec as qs

query = 'a=1&b=2&a=3&filter%5Bname%5D=Jane&flag&empty='

assert parse_qs(query, keep_blank_values=True) == {
    'a': ['1', '3'],
    'b': ['2'],
    'filter[name]': ['Jane'],
    'flag': [''],
    'empty': [''],
}
assert parse_qsl(query, keep_blank_values=True) == [
    ('a', '1'),
    ('b', '2'),
    ('a', '3'),
    ('filter[name]', 'Jane'),
    ('flag', ''),
    ('empty', ''),
]
assert qs.decode(
    query,
    qs.DecodeOptions(strict_null_handling=True),
) == {
    'a': ['1', '3'],
    'b': '2',
    'filter': {'name': 'Jane'},
    'flag': None,
    'empty': '',
}

All three decoders leave primitive values as strings. parse_qs and qs.decode combine repeated flat keys under their default behavior; parse_qsl instead retains each pair in input order. The standard-library parsers offer max_num_fields; qs.decode additionally provides default parameter, nesting-depth, and list limits plus configurable duplicate handling.

Use parse_qsl when flat pair order or duplicate interleaving matters, but not as a raw-query round-trip format: it percent-decodes names and values, normalizes + and %20 to the same space, and cannot retain the distinction between a name-only token and an explicit empty value.

Working with URLs

Use urllib.parse.urlsplit to keep URL parsing separate from query-string decoding. Pass the encoded query component directly to qs.decode without calling unquote, unquote_plus, parse_qs, or parse_qsl first:

from urllib.parse import urlsplit

import qs_codec as qs

parts = urlsplit(
    'https://example.com/search?filter%5Bname%5D=Jane%20Doe&flag#results'
)
params = qs.decode(
    parts.query,
    qs.DecodeOptions(strict_null_handling=True),
)

assert params == {
    'filter': {'name': 'Jane Doe'},
    'flag': None,
}

Passing the encoded component unchanged ensures that escaped delimiters such as %26, escaped percent signs such as %2525, and encoded bracket syntax reach qs.decode without being decoded twice.

To replace a URL query, encode fresh data and assign it to the split result's query component:

updated = parts._replace(
    query=qs.encode({
        'filter': {'name': 'John Doe'},
        'tags': ['a', 'b'],
    }),
).geturl()

assert updated == (
    'https://example.com/search?'
    'filter%5Bname%5D=John%20Doe&tags%5B0%5D=a&tags%5B1%5D=b'
    '#results'
)

Keep EncodeOptions.add_query_prefix set to False (the default) when assigning to SplitResult.query. Options such as encode=False, encode_values_only=True, or a custom encoder can emit raw URL-structural characters, so callers using them must ensure the result is safe query-component text.

This pattern replaces the existing query; it does not append or merge it. Appending or decoding and re-encoding an arbitrary query can change delimiter, duplicate-key, name-only, list-format, ordering, and percent-encoding semantics. SplitResult.geturl() may also normalize URL spelling and removes an explicit empty ? delimiter.

Decoding

dictionaries

decode allows you to create nested dicts within your query strings, by surrounding the name of sub-keys with square brackets []. For example, the string 'foo[bar]=baz' converts to:

import qs_codec as qs

assert qs.decode('foo[bar]=baz') == {'foo': {'bar': 'baz'}}

URI encoded strings work too:

import qs_codec as qs

assert qs.decode('a%5Bb%5D=c') == {'a': {'b': 'c'}}

You can also nest your dicts, like 'foo[bar][baz]=foobarbaz':

import qs_codec as qs

assert qs.decode('foo[bar][baz]=foobarbaz') == {'foo': {'bar': {'baz': 'foobarbaz'}}}

By default, when nesting dicts qs will only decode up to 5 children deep. This means if you attempt to decode a string like 'a[b][c][d][e][f][g][h][i]=j' your resulting dict will be:

import qs_codec as qs

assert qs.decode("a[b][c][d][e][f][g][h][i]=j") == {
    "a": {"b": {"c": {"d": {"e": {"f": {"[g][h][i]": "j"}}}}}}
}

This depth can be overridden by setting the depth:

import qs_codec as qs

assert qs.decode(
    'a[b][c][d][e][f][g][h][i]=j',
    qs.DecodeOptions(depth=1),
) == {'a': {'b': {'[c][d][e][f][g][h][i]': 'j'}}}

You can configure decode to throw an error when parsing nested input beyond this depth using strict_depth (defaults to False):

import qs_codec as qs

try:
    qs.decode(
        'a[b][c][d][e][f][g][h][i]=j',
        qs.DecodeOptions(depth=1, strict_depth=True),
    )
except IndexError as e:
    assert str(e) == 'Input depth exceeded depth option of 1 and strict_depth is True'

The depth limit helps mitigate abuse when decode is used to parse user input, and it is recommended to keep it a reasonably small number. strict_depth adds a layer of protection by throwing an IndexError when the limit is exceeded, allowing you to catch and handle such cases.

For similar reasons, by default decode will only parse up to 1000 parameters. This can be overridden by passing a parameter_limit option:

import qs_codec as qs

assert qs.decode(
    'a=b&c=d',
    qs.DecodeOptions(parameter_limit=1),
) == {'a': 'b'}

To bypass the leading question mark, use ignore_query_prefix:

import qs_codec as qs

assert qs.decode(
    '?a=b&c=d',
    qs.DecodeOptions(ignore_query_prefix=True),
) == {'a': 'b', 'c': 'd'}

An optional delimiter can also be passed:

import qs_codec as qs

assert qs.decode(
    'a=b;c=d',
    qs.DecodeOptions(delimiter=';'),
) == {'a': 'b', 'c': 'd'}

delimiter can be a regular expression too:

import qs_codec as qs
import re

assert qs.decode(
    'a=b;c=d',
    qs.DecodeOptions(delimiter=re.compile(r'[;,]')),
) == {'a': 'b', 'c': 'd'}

Option allow_dots can be used to enable dot notation:

import qs_codec as qs

assert qs.decode(
    'a.b=c',
    qs.DecodeOptions(allow_dots=True),
) == {'a': {'b': 'c'}}

Option decode_dot_in_keys can be used to decode dots in keys.

Note: it implies allow_dots, so decode will error if you set decode_dot_in_keys to True, and allow_dots to False.

import qs_codec as qs

assert qs.decode(
    'name%252Eobj.first=John&name%252Eobj.last=Doe',
    qs.DecodeOptions(decode_dot_in_keys=True),
) == {'name.obj': {'first': 'John', 'last': 'Doe'}}

Option allow_empty_lists can be used to allowing empty list values in a dict

import qs_codec as qs

assert qs.decode(
    'foo[]&bar=baz',
    qs.DecodeOptions(allow_empty_lists=True),
) == {'foo': [], 'bar': 'baz'}

Option duplicates can be used to change the behavior when duplicate keys are encountered

import qs_codec as qs

assert qs.decode('foo=bar&foo=baz') == {'foo': ['bar', 'baz']}

assert qs.decode(
    'foo=bar&foo=baz',
    qs.DecodeOptions(duplicates=qs.Duplicates.COMBINE),
) == {'foo': ['bar', 'baz']}

assert qs.decode(
    'foo=bar&foo=baz',
    qs.DecodeOptions(duplicates=qs.Duplicates.FIRST),
) == {'foo': 'bar'}

assert qs.decode(
    'foo=bar&foo=baz',
    qs.DecodeOptions(duplicates=qs.Duplicates.LAST),
) == {'foo': 'baz'}

If you have to deal with legacy browsers or services, there’s also support for decoding percent-encoded octets as LATIN1:

import qs_codec as qs

assert qs.decode(
    'a=%A7',
    qs.DecodeOptions(charset=qs.Charset.LATIN1),
) == {'a': '§'}

Some services add an initial utf8=✓ value to forms so that old Internet Explorer versions are more likely to submit the form as utf-8. Additionally, the server can check the value against wrong encodings of the checkmark character and detect that a query string or application/x-www-form-urlencoded body was not sent as utf-8, e.g. if the form had an accept-charset parameter or the containing page had a different character set.

decode supports this mechanism via the charset_sentinel option. If specified, the utf8 parameter will be omitted from the returned dict. It will be used to switch to LATIN1 or UTF8 mode depending on how the checkmark is encoded.

Important: When you specify both the charset option and the charset_sentinel option, the charset will be overridden when the request contains a utf8 parameter from which the actual charset can be deduced. In that sense the charset will behave as the default charset rather than the authoritative charset.

import qs_codec as qs

assert qs.decode(
    'utf8=%E2%9C%93&a=%C3%B8',
    qs.DecodeOptions(
        charset=qs.Charset.LATIN1,
        charset_sentinel=True,
    ),
) == {'a': 'ø'}

assert qs.decode(
    'utf8=%26%2310003%3B&a=%F8',
    qs.DecodeOptions(
        charset=qs.Charset.UTF8,
        charset_sentinel=True,
    ),
) == {'a': 'ø'}

If you want to decode the &#...; syntax to the actual character, you can specify the interpret_numeric_entities option as well:

import qs_codec as qs

assert qs.decode(
    'a=%26%239786%3B',
    qs.DecodeOptions(
        charset=qs.Charset.LATIN1,
        interpret_numeric_entities=True,
    ),
) == {'a': '☺'}

It also works when the charset has been detected in charset_sentinel mode.

lists

decode can also decode lists using a similar [] notation:

import qs_codec as qs

assert qs.decode('a[]=b&a[]=c') == {'a': ['b', 'c']}

You may specify an index as well:

import qs_codec as qs

assert qs.decode('a[1]=c&a[0]=b') == {'a': ['b', 'c']}

Note that the only difference between an index in a list and a key in a dict is that the value between the brackets must be a number to create a list. When creating lists with specific indices, decode will compact a sparse list to only the existing values preserving their order:

import qs_codec as qs

assert qs.decode('a[1]=b&a[15]=c') == {'a': ['b', 'c']}

Note that an empty string is also a value, and will be preserved:

import qs_codec as qs

assert qs.decode('a[]=&a[]=b') == {'a': ['', 'b']}

assert qs.decode('a[0]=b&a[1]=&a[2]=c') == {'a': ['b', '', 'c']}

decode also limits each list to a maximum element count of 20. Index 19 is the last index that can create a default list; index 20 and higher are converted to a dict with the index as the key. This prevents inputs such as a[999999999] from creating massive sparse lists.

import