# Qs Codec

> Use this skill whenever a user wants to install, configure, troubleshoot, or write Python application code for encoding and decoding nested query strings with the qs-codec package, including comparing or composing qs-codec with urllib.parse.urlencode, parse_qs, parse_qsl, and URL components. This skill helps produce practical qs_codec.decode, qs_codec.encode, qs_codec.loads, and qs_codec.dumps snippets, choose DecodeOptions and EncodeOptions, explain option tradeoffs, and avoid qs-codec edge-case pitfalls around lists, dot notation, duplicates, null handling, charset sentinels, depth limits, URL replacement, and untrusted input.

- Skill: `techouse/qs-codec` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add techouse/qs-codec`
- Raw SKILL.md: https://api.skillmd.com/api/skills/techouse/qs-codec/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: techouse (https://skillmd.com/u/techouse)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/techouse/qs-codec

---


# qs-codec Usage Assistant

Help users parse and build query strings with the Python `qs-codec` package.
Focus on user application code and interoperability outcomes, not repository
maintenance.

## Start With Inputs

Before producing a final snippet, collect only the missing details that change
the code:

- Runtime: Python script, web framework, tests, library code, or generated
  example.
- Direction: decode an incoming query string, encode Python data, or normalize
  query-string handling around an existing URL/request object.
- The actual query string or Python structure when available.
- Target API convention for lists: indexed brackets, empty brackets, repeated
  keys, or comma-separated values.
- Whether the query may include a leading `?`, dot notation, literal dots in
  keys, duplicate keys, custom delimiters, comma-separated lists, `None` flags,
  Latin-1/legacy charset behavior, or untrusted user input.

Do not over-ask when the desired behavior is obvious. State assumptions in the
answer and give the user a concrete snippet they can paste.

## Installation

Install the package from PyPI:

```bash
python -m pip install qs-codec
```

Use the package-level public API:

```python
import qs_codec as qs
```

When snippets use regex delimiters, dates, or custom codecs, include the needed
standard-library imports such as `re`, `datetime`, `codecs`, or `typing`.

## Base Patterns

Decode a query string into nested Python values:

```python
import qs_codec as qs

params = qs.decode("a[b][c]=d&tags[]=python&tags[]=web")
assert params == {"a": {"b": {"c": "d"}}, "tags": ["python", "web"]}
```

Encode nested Python values into a query string:

```python
import qs_codec as qs

query = qs.encode({
    "a": {"b": {"c": "d"}},
    "tags": ["python", "web"],
})
assert query == "a%5Bb%5D%5Bc%5D=d&tags%5B0%5D=python&tags%5B1%5D=web"
```

Use `qs.loads(...)` as a string-only alias for `qs.decode(...)`, and
`qs.dumps(...)` as an alias for `qs.encode(...)`.

## Standard-Library Codec Selection

Choose `urllib.parse.urlencode`, `parse_qs`, and `parse_qsl` for conventional
flat `application/x-www-form-urlencoded` data. Choose `qs-codec` when callers
need nested dictionaries or lists, Node `qs` interoperability, configurable
list, duplicate, or null semantics, or matching structure-aware encode/decode
behavior.

Apply these distinctions when comparing the APIs:

- Treat `urlencode` as a flat encoder. With `doseq=True`, expand sequence
  values as repeated keys; use `ListFormat.REPEAT` for the equivalent
  `qs.encode` output. Do not pass nested mappings to `urlencode` expecting
  recursive query paths.
- Account for different defaults: `urlencode` emits spaces as `+` and uses
  Python scalar spellings such as `True` and `None`; `qs.encode` defaults to
  `%20`, lowercase booleans, and an empty value for `None`.
- Treat `parse_qs` as a grouped flat parser whose dictionary values are always
  lists. It leaves bracket syntax in literal keys, drops blanks unless
  `keep_blank_values=True`, and cannot distinguish a name-only token from an
  explicit empty value.
- Prefer `parse_qsl` when flat pair order and interleaved duplicates matter. It
  returns an ordered list of name/value pairs, but otherwise shares
  `parse_qs`'s literal bracket, blank-value, and null-distinction limitations.
  It also percent-decodes names and values and normalizes `+` and `%20`, so do
  not present its output as a lossless representation of the raw query.
- Use `qs.decode` to reconstruct bracket or dot paths. A singleton normally
  remains scalar, duplicate policies support combine/first/last, and
  `strict_null_handling=True` distinguishes a bare name as `None` from an
  explicit empty string.
- Note that all three parsers leave primitive values as strings. `parse_qs` and
  `qs.decode` combine repeated flat keys by default, while `parse_qsl` retains
  each occurrence. For resource limits, compare the standard-library parsers'
  `max_num_fields` with `DecodeOptions.parameter_limit`, `depth`, and
  `list_limit` plus `raise_on_limit_exceeded`.

Do not preprocess a complete URL's raw query with `parse_qs` or `parse_qsl`
before `qs.decode`; doing so flattens structured syntax and loses name-only
distinctions.

## Standard-Library URL Recipes

Use `urllib.parse.urlsplit` to separate URL parsing from qs decoding. Pass the
encoded `.query` component directly to `qs.decode`; do not call `unquote`,
`unquote_plus`, `parse_qs`, or `parse_qsl` first. Pre-decoding can turn escaped
delimiters into structure, double-decode percent signs, and flatten qs bracket
syntax.

```python
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}
```

For a `bytes` URL, `urlsplit` returns a byte query while `qs.decode` accepts
text. Use `.query.decode("ascii")` only when the application boundary guarantees
a conforming ASCII percent-encoded URL; otherwise ask the caller to define the
outer byte-decoding policy.

Replace a query with freshly encoded data:

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

Apply these constraints when recommending URL composition:

- Keep `EncodeOptions.add_query_prefix=False` when assigning to
  `SplitResult.query`; a prefixed encoded value creates `??`.
- Default percent-encoded output is appropriate for replacement. Treat
  `encode=False`, `encode_values_only=True`, custom encoders, and raw query text
  as caller-managed because they can emit `#`, `&`, `?`, or malformed percent
  escapes.
- Describe `_replace(query=...).geturl()` as replacement, not append or merge.
  It intentionally discards the existing query and may normalize URL spelling;
  empty encoded output removes an explicit query delimiter.
- Do not propose a generic append helper when existing and new queries may use
  different delimiters. Mixing `&` and `;` cannot be interpreted generally
  without choosing a parser and rewriting one side.
- Do not decode and re-encode an arbitrary existing query to "normalize" it.
  That can regroup interleaved duplicates, convert bare names to empty values,
  change list formats and delimiters, reorder tokens, and select new percent
  spellings.
- Use the direct standard-library expression for raw replacement. A wrapper
  around `_replace(query=raw).geturl()` does not add validation or escaping.

## Decode Recipes

Use these options with `qs.decode(query, qs.DecodeOptions(...))`:

- Leading question mark: `ignore_query_prefix=True`.
- Dot notation such as `a.b=c`: `allow_dots=True`.
- Double-encoded literal dots in keys such as `name%252Eobj.first=John`:
  `decode_dot_in_keys=True`.
- Duplicate keys: `duplicates=qs.Duplicates.COMBINE` keeps all values as a
  list; use `qs.Duplicates.FIRST` or `qs.Duplicates.LAST` to collapse.
- Bracket lists: enabled by default; set `parse_lists=False` to treat list
  syntax as dictionary keys.
- List limits: default `list_limit` is `20`; numeric indices at or above the
  limit become dictionary keys. The limit also applies cumulatively to lists
  grown by duplicate keys, mixed notation, or comma-separated values. Exact-limit
  results remain lists; soft overflow becomes a numeric-keyed dictionary, while
  `raise_on_limit_exceeded=True` raises `ValueError`.
- Comma-separated values such as `a=b,c`: `comma=True`.
- Tokens without `=` as `None`: `strict_null_handling=True`.
- Custom delimiters: `delimiter=";"` or `delimiter=re.compile(r"[;,]")`.
- Legacy charset input: `charset=qs.Charset.LATIN1`; use
  `charset_sentinel=True` when a form may include `utf8=...` to signal the real
  charset.
- HTML numeric entities: `interpret_numeric_entities=True`, usually with
  Latin-1 or charset sentinel handling.
- Untrusted input: keep `depth`, `parameter_limit`, and `list_limit` bounded;
  use `strict_depth=True` plus `raise_on_limit_exceeded=True` when callers need
  hard failures instead of soft limiting.

Example for a request query:

```python
import qs_codec as qs

params = qs.decode(
    "?filter.status=open&tag=python&tag=web",
    qs.DecodeOptions(
        ignore_query_prefix=True,
        allow_dots=True,
        duplicates=qs.Duplicates.COMBINE,
    ),
)
assert params == {"filter": {"status": "open"}, "tag": ["python", "web"]}
```

## Encode Recipes

Use these options with `qs.encode(data, qs.EncodeOptions(...))`:

- List style defaults to `qs.ListFormat.INDICES`:
  `tags%5B0%5D=python&tags%5B1%5D=web`.
- Empty brackets: `list_format=qs.ListFormat.BRACKETS`.
- Repeated keys: `list_format=qs.ListFormat.REPEAT`.
- Comma-separated values: `list_format=qs.ListFormat.COMMA`.
- Single-item comma lists that must round-trip as lists:
  `comma_round_trip=True`.
- Drop `None` items before comma-joining lists: `comma_compact_nulls=True`.
- Dot notation for nested dictionaries: `allow_dots=True`.
- Literal dots in keys: `encode_dot_in_keys=True`; leave `allow_dots`
  unspecified or set it explicitly based on whether nested paths should use
  dot notation.
- Add a leading `?`: `add_query_prefix=True`.
- Custom pair delimiter: `delimiter=";"`.
- Preserve readable bracket/dot keys while encoding values:
  `encode_values_only=True`.
- Disable percent encoding entirely for debugging or documented examples:
  `encode=False`.
- Emit `None` without `=`: `strict_null_handling=True`.
- Omit `None` keys: `skip_nulls=True`.
- Emit empty lists as `foo[]`: `allow_empty_lists=True`.
- Omit arbitrary keys by filtering them out of the input mapping before
  encoding; avoid internal sentinels in application snippets.
- Legacy form spaces as `+`: `format=qs.Format.RFC1738`; the default is
  `qs.Format.RFC3986`, which emits spaces as `%20`.
- Legacy charset output: `charset=qs.Charset.LATIN1`; use
  `charset_sentinel=True` to prepend the `utf8=...` sentinel.
- Custom behavior: use `encoder`, `serialize_date`, `sort`, or `filter` when
  the target API needs special scalar encoding, date formatting, stable key
  order, or selected fields.
- Maximum traversal depth: `max_depth=<positive int>`; `None` means unbounded by
  this option.

Example for an API that expects repeated keys:

```python
import qs_codec as qs

query = qs.encode(
    {
        "q": "query strings",
        "tag": ["python", "web"],
    },
    qs.EncodeOptions(
        list_format=qs.ListFormat.REPEAT,
        add_query_prefix=True,
    ),
)
assert query == "?q=query%20strings&tag=python&tag=web"
```

## Combinations To Check

Warn or adjust before giving code for these cases:

- `qs.DecodeOptions(decode_dot_in_keys=True, allow_dots=False)` is invalid.
- `parameter_limit` must be positive or `float("inf")`; use
  `raise_on_limit_exceeded=True` to raise when the limit is exceeded instead of
  silently truncating.
- `list_limit` has nuanced list-construction behavior; negative values disable
  numeric-index list parsing, and `raise_on_limit_exceeded=True` turns list
  limit violations into `ValueError`. With `comma=True`, a flat comma value is
  checked before value decoding, while a comma group assigned through `[]=`
  counts as one outer list element.
- Built-in charset handling supports only `qs.Charset.UTF8` and
  `qs.Charset.LATIN1`; other encodings require a custom `encoder` or `decoder`.
- `EncodeOptions.encoder` is ignored when `encode=False`.
- Combining `encode_values_only=True` and `encode_dot_in_keys=True` encodes only
  dots in keys; values remain otherwise unchanged.
- `DecodeOptions.comma` parses simple comma-separated values, but does not
  decode nested dictionary syntax such as `a={b:1},{c:d}`.
- `encode(None)`, scalar roots, empty dictionaries, and empty containers
  generally produce an empty string.
- The standard library and many web frameworks flatten duplicates or nested
  query syntax. Prefer `qs.decode` on the raw query string when qs-style nested
  or repeated values matter.

## Response Shape

For code-generation requests, answer with:

1. A short statement of assumptions, especially list format, null handling,
   charset, prefix handling, and whether input is trusted.
2. One concrete Python snippet using `qs.decode`, `qs.encode`, `qs.loads`, or
   `qs.dumps`.
3. A brief explanation of only the options used.
4. A small verification example, such as an expected dictionary, expected query
   string, or a `pytest` assertion.

Keep snippets application-oriented. Prefer public API imports from `qs_codec`;
do not ask users to import from `qs_codec.src` or private modules.

