A query string encoding and decoding library for C#/.NET.
Ported from qs for JavaScript.
- Nested dictionaries and lists:
foo[bar][baz]=qux⇄{ "foo": { "bar": { "baz": "qux" } } } - Multiple list formats (indices, brackets, repeat, comma)
- Dot-notation support (
a.b=c) and"."-encoding toggles - UTF-8 and Latin1 charsets, plus optional charset sentinel (
utf8=✓) - Custom encoders/decoders, key sorting, filtering, and strict null handling
- Supports
DateTimeserialization via a pluggable serializer - Extensive tests (xUnit + FluentAssertions), performance-minded implementation
Install-Package QsNet
dotnet add package QsNet<PackageReference Include="QsNet" Version="<version>"/>Core QsNet stays framework-agnostic. Install an adapter package only when you
need integration with a specific HTTP or URL library.
| Package | Use when | Install |
|---|---|---|
QsNet.AspNetCore |
You want ASP.NET Core helpers for parsing or appending qs-style query strings. | dotnet add package QsNet.AspNetCore |
QsNet.Flurl |
You build URLs with Flurl and want qs-style nested query parameters. | dotnet add package QsNet.Flurl |
QsNet.Refit |
You use Refit and need to pass a QsNet-generated query string through a request interface. | dotnet add package QsNet.Refit |
QsNet.RestSharp |
You use RestSharp and want to add QsNet-generated nested query parameters to a RestRequest. |
dotnet add package QsNet.RestSharp |
QsNet.Refit does not depend on Refit at runtime. Install Refit separately in
the application or test project that declares the Refit API interface.
RestEase does not need a dedicated QsNet
adapter. Encode with the core QsNet package and pass the result through
RestEase's built-in [RawQueryString] parameter as shown below.
- Target frameworks (TFMs):
net10.0,netstandard2.0 - Supported runtimes (via the target frameworks above):
| Runtime | Version | CI Coverage | Status |
|---|---|---|---|
| .NET | 10 (LTS) | Full CI | Supported |
| .NET | 9 (STS) | Consumer smoke test | Supported |
| .NET | 8 (LTS) | Consumer smoke test | Supported |
| .NET | 7 | Consumer smoke test | Supported |
| .NET | 6 (LTS) | Consumer smoke test | Supported |
| .NET | 5 | Optional smoke (non-blocking) | EOL |
| .NET Core | 3.1 | Compile-only smoke | EOL |
| .NET Framework | 4.6.1+ | Smoke test (4.6.1, 4.8.1) | Supported |
- Platforms: Windows, Linux, macOS (cross-platform, no native dependencies)
using QsNet;
// Decode
Dictionary<string, object?> obj = Qs.Decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b");
// -> { "foo": { "bar": "baz", "list": ["a", "b"] } }
// Encode
string qs = Qs.Encode(new Dictionary<string, object?>
{
["foo"] = new Dictionary<string, object?> { ["bar"] = "baz" }
});
// -> "foo%5Bbar%5D=baz"using QsNet.AspNetCore;
string url = "/api/search".AddQueryString(new Dictionary<string, object?>
{
["filter"] = new Dictionary<string, object?> { ["name"] = "Alice" }
});
// -> "/api/search?filter%5Bname%5D=Alice"
Dictionary<string, object?> query = httpContext.Request.ToQueryMap();QsNet.AspNetCore appends the already encoded QsNet output directly, preserving
fragments and bracket notation without re-encoding through ASP.NET Core
QueryHelpers.
using Flurl;
using QsNet.Flurl;
var url = "https://api.example.com"
.AppendPathSegment("products")
.AppendQsQueryParams(new
{
filter = new { name = "Alice" },
tags = new[] { "one", "two" },
});
// -> "https://api.example.com/products?filter%5Bname%5D=Alice&tags%5B0%5D=one&tags%5B1%5D=two"QsNet.Flurl writes QsNet's already encoded output through Flurl's Url.Query
instead of Flurl's normal query-parameter APIs, avoiding double-encoding of
qs-style bracket notation.
using QsNet.Models;
using QsNet.Refit;
using Refit;
public interface IUsersApi
{
[Get("/users")]
[QueryUriFormat(UriFormat.Unescaped)]
Task<List<User>> GetUsers([Query] QsQuery query);
}
await api.GetUsers(QsQuery.From(
new
{
Roles = new[]
{
new { Name = "Developer", Level = 1 },
},
},
new EncodeOptions { AllowDots = true }));
// -> "/users?Roles%5B0%5D.Name=Developer&Roles%5B0%5D.Level=1"QsNet.Refit formalizes a wrapper workaround for complex nested query strings.
It does not change Refit's native [Query] object serializer for DTOs.
using QsNet;
using QsNet.Enums;
using QsNet.Models;
using RestEase;
public interface IProductsApi
{
[Get("products")]
Task<Response<Product[]>> SearchAsync(
[RawQueryString] string query,
CancellationToken cancellationToken = default
);
}
var query = Qs.Encode(
new Dictionary<string, object?>
{
["filter"] = new Dictionary<string, object?>
{
["where"] = new Dictionary<string, object?> { ["name"] = "John" },
},
["tags"] = new[] { "a", "b" },
["flag"] = null,
["empty"] = "",
},
new EncodeOptions
{
ListFormat = ListFormat.Brackets,
StrictNullHandling = true,
}
);
await api.SearchAsync(query, cancellationToken);[RawQueryString] inserts QsNet's encoded query verbatim, preserving bracket
syntax, duplicate order, name-only nulls, %20 or + spaces, and encoded
percent signs. Do not route the encoded result through [Query], [QueryMap],
or a custom query serializer; those paths encode returned names and values
again.
Pass a fragment without a leading ? or trailing &. RestEase joins an
existing method query, multiple raw fragments, and normal query parameters with
&, so a custom QsNet delimiter is safe only when the raw QsNet fragment is
the entire query contribution. JSON and form bodies remain independent.
using QsNet.RestSharp;
using RestSharp;
var request = new RestRequest("products")
.AddQsQueryParameters(new
{
filter = new
{
where = new
{
name = "John",
age = new { gte = 30 },
},
},
tags = new[] { "a", "b" },
});
// -> "/products?filter%5Bwhere%5D%5Bname%5D=John&filter%5Bwhere%5D%5Bage%5D%5Bgte%5D=30&tags%5B0%5D=a&tags%5B1%5D=b"QsNet.RestSharp adds already encoded QsNet query pairs to RestSharp with
query encoding disabled, avoiding double-encoding of qs-style bracket notation.
// Decode
Dictionary<string, object?> decoded = Qs.Decode("a=c");
// => { "a": "c" }
// Encode
string encoded = Qs.Encode(new Dictionary<string, object?> { ["a"] = "c" });
// => "a=c"Use DecodeQsQuery to decode the escaped query component of an absolute or
relative Uri without pre-decoding percent escapes or including the fragment:
var uri = new Uri(
"https://example.com/search?filter%5Bwhere%5D%5Bname%5D=John%20Doe&tag=a&tag=b&flag&empty=#results"
);
var query = uri.DecodeQsQuery(
new DecodeOptions { StrictNullHandling = true }
);
// => { "filter": { "where": { "name": "John Doe" } }, "tag": ["a", "b"], "flag": null, "empty": "" }Here, encoded brackets reach QsNet unchanged, the fragment is ignored, the two
tag values use the configured duplicate handling, flag decodes as null,
and empty= decodes as an empty string. An absent or empty query returns an
empty dictionary.
The helper is intentionally read-only. For a new URI with no existing query,
construct the URI explicitly from default Qs.Encode(...) output:
var values = new Dictionary<string, object?> { ["page"] = "2" };
var encoded = Qs.Encode(values);
var uri = new Uri($"https://api.example.com/products?{encoded}");AddQueryPrefix is false by default. If you enable it, interpolate the
encoded output without adding another literal ?, or remove the prefix first.
Do not use this pattern to merge with an arbitrary existing query: decoding and
re-encoding can change duplicate ordering, name-only keys, delimiters, list
notation, and percent spelling. Encode = false or a custom encoder can also
produce text unsuitable for Uri construction.
Qs.Decode("foo[bar]=baz");
// => { "foo": { "bar": "baz" } }
Qs.Decode("a%5Bb%5D=c");
// => { "a": { "b": "c" } }
Qs.Decode("foo[bar][baz]=foobarbaz");
// => { "foo": { "bar": { "baz": "foobarbaz" } } }Beyond the configured depth, remaining bracket content is kept as literal text:
Qs.Decode("a[b][c][d][e][f][g][h][i]=j");
// => { "a": { "b": { "c": { "d": { "e": { "f": { "[g][h][i]": "j" } } } } } } }Override depth:
Qs.Decode("a[b][c][d][e][f][g][h][i]=j", new DecodeOptions { Depth = 1 });
// => { "a": { "b": { "[c][d][e][f][g][h][i]": "j" } } }Qs.Decode("a=b&c=d", new DecodeOptions { ParameterLimit = 1 });
// => { "a": "b" }Qs.Decode("?a=b&c=d", new DecodeOptions { IgnoreQueryPrefix = true });
// => { "a": "b", "c": "d" }Qs.Decode("a=b;c=d", new DecodeOptions { Delimiter = new StringDelimiter(";") });
// => { "a": "b", "c": "d" }
Qs.Decode("a=b;c=d", new DecodeOptions { Delimiter = new RegexDelimiter("[;,]") });
// => { "a": "b", "c": "d" }Qs.Decode("a.b=c", new DecodeOptions { AllowDots = true });
// => { "a": { "b": "c" } }
Qs.Decode(
"name%252Eobj.first=John&name%252Eobj.last=Doe",
new DecodeOptions { DecodeDotInKeys = true }
);
// => { "name.obj": { "first": "John", "last": "Doe" } }Qs.Decode("foo[]&bar=baz", new DecodeOptions { AllowEmptyLists = true });
// => { "foo": [], "bar": "baz" }Qs.Decode("foo=bar&foo=baz");
// => { "foo": ["bar", "baz"] }
Qs.Decode("foo=bar&foo=baz", new DecodeOptions { Duplicates = Duplicates.Combine });
// => same as above
Qs.Decode("foo=bar&foo=baz", new DecodeOptions { Duplicates = Duplicates.First });
// => { "foo": "bar" }
Qs.Decode("foo=bar&foo=baz", new DecodeOptions { Duplicates = Duplicates.Last });
// => { "foo": "baz" }Bracket-array keys always combine, even when plain duplicate keys are configured to keep only the first or last value:
Qs.Decode("foo[]=bar&foo[]=baz", new DecodeOptions { Duplicates = Duplicates.First });
// => { "foo": ["bar", "baz"] }Object/primitive merge conflicts wrap into a list by default:
Qs.Decode("a[b]=c&a=d");
// => { "a": [{ "b": "c" }, "d"] }Set StrictMerge to false to preserve the legacy QsNet behavior for
object-then-primitive conflicts:
Qs.Decode("a[b]=c&a=d", new DecodeOptions { StrictMerge = false });
// => { "a": { "b": "c", "d": true } }// Latin1
Qs.Decode("a=%A7", new DecodeOptions { Charset = Encoding.Latin1 });
// => { "a": "§" }
// Sentinels
Qs.Decode("utf8=%E2%9C%93&a=%C3%B8", new DecodeOptions { Charset = Encoding.Latin1, CharsetSentinel = true });
// => { "a": "ø" }
Qs.Decode("utf8=%26%2310003%3B&a=%F8", new DecodeOptions { Charset = Encoding.UTF8, CharsetSentinel = true });
// => { "a": "ø" }Qs.Decode(
"a=%26%239786%3B",
new DecodeOptions { Charset = Encoding.Latin1, InterpretNumericEntities = true }
);
// => { "a": "☺" }Qs.Decode("a[]=b&a[]=c");
// => { "a": ["b", "c"] }
Qs.Decode("a[1]=c&a[0]=b");
// => { "a": ["b", "c"] }
Qs.Decode("a[1]=b&a[15]=c");
// => { "a": ["b", "c"] }
Qs.Decode("a[]=&a[]=b");
// => { "a": ["", "b"] }ListLimit is the maximum element count for lists. Explicit numeric indices are
list entries only when index < ListLimit; an index at or above the limit
becomes a dictionary entry by default, or throws when ThrowOnLimitExceeded is
true. Implicit list growth, comma lists, and duplicate-combine paths use the same
element count before overflow conversion or exception.
Large indices convert to a dictionary by default:
Qs.Decode("a[100]=b");
// => { "a": { 100: "b" } }Disable list parsing:
Qs.Decode("a[]=b", new DecodeOptions { ParseLists = false });
// => { "a": { 0: "b" } }Mixing notations merges into a dictionary:
Qs.Decode("a[0]=b&a[b]=c");
// => { "a": { 0: "b", "b": "c" } }Comma-separated values:
Qs.Decode("a=b,c", new DecodeOptions { Comma = true });
// => { "a": ["b", "c"] }All values decode as strings by default:
Qs.Decode("a=15&b=true&c=null");
// => { "a": "15", "b": "true", "c": "null" }Qs.Encode(new Dictionary<string, object?> { ["a"] = "b" });
// => "a=b"
Qs.Encode(new Dictionary<string, object?>
{
["a"] = new Dictionary<string, object?> { ["b"] = "c" }
});
// => "a%5Bb%5D=c"Disable URI encoding for readability:
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = new Dictionary<string, object?> { ["b"] = "c" }
},
new EncodeOptions { Encode = false }
);
// => "a[b]=c"Values-only encoding:
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = "b",
["c"] = new List<object?> { "d", "e=f" },
["f"] = new List<object?>
{
new List<object?> { "g" },
new List<object?> { "h" },
},
},
new EncodeOptions { EncodeValuesOnly = true }
);
// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"Custom encoder:
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = new Dictionary<string, object?> { ["b"] = "č" },
},
new EncodeOptions
{
Encoder = (str, _, _) => str?.ToString() == "č" ? "c" : str?.ToString() ?? "",
}
);
// => "a[b]=c"var data = new Dictionary<string, object?> { ["a"] = new List<object?> { "b", "c" } };
var options = new EncodeOptions { Encode = false };
// default (indices)
Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Indices));
// => "a[0]=b&a[1]=c"
// brackets
Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Brackets));
// => "a[]=b&a[]=c"
// repeat
Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Repeat));
// => "a=b&a=c"
// comma
Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Comma));
// => "a=b,c"Note: When ListFormat.Comma is selected, you can set EncodeOptions.CommaRoundTrip to true or false to append
[] on single-item lists so they round-trip through decoding. Set EncodeOptions.CommaCompactNulls to true alongside
the comma format when you'd like to drop null entries instead of keeping empty slots (for example,
["one", null, "two"] becomes one,two).
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = new Dictionary<string, object?>
{
["b"] = new Dictionary<string, object?> { ["c"] = "d", ["e"] = "f" },
},
},
new EncodeOptions { Encode = false }
);
// => "a[b][c]=d&a[b][e]=f"Dot notation:
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = new Dictionary<string, object?>
{
["b"] = new Dictionary<string, object?> { ["c"] = "d", ["e"] = "f" },
},
},
new EncodeOptions { Encode = false, AllowDots = true }
);
// => "a.b.c=d&a.b.e=f"Encode dots in keys:
Qs.Encode(
new Dictionary<string, object?>
{
["name.obj"] = new Dictionary<string, object?>
{
["first"] = "John",
["last"] = "Doe",
},
},
new EncodeOptions { AllowDots = true, EncodeDotInKeys = true }
);
// => "name%252Eobj.first=John&name%252Eobj.last=Doe"Allow empty lists:
Qs.Encode(
new Dictionary<string, object?> { ["foo"] = new List<object?>(), ["bar"] = "baz" },
new EncodeOptions { Encode = false, AllowEmptyLists = true }
);
// => "foo[]&bar=baz"Empty strings and nulls:
Qs.Encode(new Dictionary<string, object?> { ["a"] = "" });
// => "a="Return empty string for empty containers:
Qs.Encode(new Dictionary<string, object?> { ["a"] = new List<object?>() }); // => ""
Qs.Encode(new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?>() }); // => ""
Qs.Encode(new Dictionary<string, object?> { ["a"] = new List<object?> { new Dictionary<string, object?>() } }); // => ""
Qs.Encode(new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?> { ["b"] = new List<object?>() } }); // => ""
Qs.Encode(new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?> { ["b"] = new Dictionary<string, object?>() } }); // => ""Omit Undefined:
Qs.Encode(new Dictionary<string, object?> { ["a"] = null, ["b"] = Undefined.Create() });
// => "a="Add query prefix:
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "b", ["c"] = "d" },
new EncodeOptions { AddQueryPrefix = true }
);
// => "?a=b&c=d"Custom delimiter:
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "b", ["c"] = "d" },
new EncodeOptions { Delimiter = ";" }
);
// => "a=b;c=d"By default, DateTime is serialized using ToString() in ISO 8601 format.
var date = new DateTime(1970, 1, 1, 0, 0, 0, 7, DateTimeKind.Utc);
Qs.Encode(
new Dictionary<string, object?> { ["a"] = date },
new EncodeOptions { Encode = false }
);
// => "a=1970-01-01T00:00:00.0070000Z"
Qs.Encode(
new Dictionary<string, object?> { ["a"] = date },
new EncodeOptions
{
Encode = false,
DateSerializer = d => ((DateTimeOffset)d).ToUnixTimeMilliseconds().ToString(),
}
);
// => "a=7"// Sort keys
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = "c",
["z"] = "y",
["b"] = "f",
},
new EncodeOptions
{
Encode = false,
Sort = (a, b) => string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal),
}
);
// => "a=c&b=f&z=y"
// Filter by function (drop/transform values)
var epochStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var testDate = epochStart.AddMilliseconds(123);
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = "b",
["c"] = "d",
["e"] = new Dictionary<string, object?>
{
["f"] = testDate,
["g"] = new List<object?> { 2 },
},
},
new EncodeOptions
{
Encode = false,
Filter = new FunctionFilter(
(prefix, value) =>
prefix switch
{
"b" => Undefined.Create(),
"e[f]" => (long)((DateTime)value! - epochStart).TotalMilliseconds,
"e[g][0]" => Convert.ToInt32(value) * 2,
_ => value,
}
),
}
);
// => "a=b&c=d&e[f]=123&e[g][0]=4"
// Filter by explicit list of keys/indices
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = "b",
["c"] = "d",
["e"] = "f",
},
new EncodeOptions
{
Encode = false,
Filter = new IterableFilter(new List<object> { "a", "e" }),
}
);
// => "a=b&e=f"
Qs.Encode(
new Dictionary<string, object?>
{
["a"] = new List<object?> { "b", "c", "d" },
["e"] = "f",
},
new EncodeOptions
{
Encode = false,
Filter = new IterableFilter(new List<object> { "a", 0, 2 }),
}
);
// => "a[0]=b&a[2]=d"// Treat null values like empty strings by default
Qs.Encode(new Dictionary<string, object?> { ["a"] = null, ["b"] = "" });
// => "a=&b="
// Cannot distinguish between parameters with and without equal signs
Qs.Decode("a&b=");
// => { "a": "", "b": "" }
// Distinguish between null values and empty strings using strict null handling
Qs.Encode(
new Dictionary<string, object?> { ["a"] = null, ["b"] = "" },
new EncodeOptions { StrictNullHandling = true }
);
// => "a&b="
// Decode values without equals back to null using strict null handling
Qs.Decode("a&b=", new DecodeOptions { StrictNullHandling = true });
// => { "a": null, "b": "" }
// Completely skip rendering keys with null values using skip nulls
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "b", ["c"] = null },
new EncodeOptions { SkipNulls = true }
);
// => "a=b"Note (Latin-1 on older TFMs): Some frameworks (e.g., netstandard2.0) don’t expose
Encoding.Latin1directly. UseEncoding.GetEncoding("iso-8859-1"). On .NET Core / netstandard you may also need to register the code pages provider:using System.Text; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var latin1 = Encoding.GetEncoding("iso-8859-1");
// Encode using Latin1 charset
Qs.Encode(
new Dictionary<string, object?> { ["æ"] = "æ" },
new EncodeOptions { Charset = Encoding.Latin1 }
);
// => "%E6=%E6"
// Convert characters that don't exist in Latin1 to numeric entities
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "☺" },
new EncodeOptions { Charset = Encoding.Latin1 }
);
// => "a=%26%239786%3B"
// Announce charset using charset sentinel option with UTF-8
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "☺" },
new EncodeOptions { CharsetSentinel = true }
);
// => "utf8=%E2%9C%93&a=%E2%98%BA"
// Announce charset using charset sentinel option with Latin1
Qs.Encode(
new Dictionary<string, object?> { ["a"] = "æ" },
new EncodeOptions { Charset = Encoding.Latin1, CharsetSentinel = true }
);
// => "utf8=%26%2310003%3B&a=%E6"Qs.Encode(new Dictionary<string, object?> { ["a"] = "b c" });
// => "a=b%20c" (RFC 3986 default)
Qs.Encode(new Dictionary<string, object?> { ["a"] = "b c" }, new EncodeOptions { Format = Format.Rfc3986 });
// => "a=b%20c"
Qs.Encode(new Dictionary<string, object?> { ["a"] = "b c" }, new EncodeOptions { Format = Format.Rfc1738 });
// => "a=b+c"- Performance: The implementation mirrors qs semantics but is optimized for C#/.NET. Deep parsing, list compaction, and cycle-safe compaction are implemented iteratively where it matters.
- Safety: Defaults (depth, parameterLimit) help mitigate abuse in user-supplied inputs; you can loosen them when you fully trust the source.
- Interop: Exposes knobs similar to qs (filters, sorters, custom encoders/decoders) to make migrations straightforward.
| Port | Repository | Package |
|---|---|---|
| Dart | techouse/qs | |
| Python | techouse/qs_codec | |
| Kotlin / JVM + Android AAR | techouse/qs-kotlin | |
| Swift / Objective-C | techouse/qs-swift | |
| Rust | techouse/qs_rust | |
| Node.js (original) | ljharb/qs |
Special thanks to the authors of qs for JavaScript:
BSD 3-Clause © techouse
