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<PackageReferenceInclude="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)
usingQsNet;// DecodeDictionary<string,object?>obj=Qs.Decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b");// -> { "foo": { "bar": "baz", "list": ["a", "b"] } }// Encodestringqs=Qs.Encode(newDictionary<string,object?>{["foo"]=newDictionary<string,object?>{["bar"]="baz"}});// -> "foo%5Bbar%5D=baz"usingQsNet.AspNetCore;stringurl="/api/search".AddQueryString(newDictionary<string,object?>{["filter"]=newDictionary<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.
usingFlurl;usingQsNet.Flurl;varurl="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.
usingQsNet.Models;usingQsNet.Refit;usingRefit;publicinterfaceIUsersApi{[Get("/users")][QueryUriFormat(UriFormat.Unescaped)]Task<List<User>>GetUsers([Query]QsQueryquery);}awaitapi.GetUsers(QsQuery.From(new{Roles=new[]{new{Name="Developer",Level=1},},},newEncodeOptions{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.
usingQsNet;usingQsNet.Enums;usingQsNet.Models;usingRestEase;publicinterfaceIProductsApi{[Get("products")]Task<Response<Product[]>>SearchAsync([RawQueryString]stringquery,CancellationTokencancellationToken=default);}varquery=Qs.Encode(newDictionary<string,object?>{["filter"]=newDictionary<string,object?>{["where"]=newDictionary<string,object?>{["name"]="John"},},["tags"]=new[]{"a","b"},["flag"]=null,["empty"]="",},newEncodeOptions{ListFormat=ListFormat.Brackets,StrictNullHandling=true,});awaitapi.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.
usingQsNet.RestSharp;usingRestSharp;varrequest=newRestRequest("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.
// DecodeDictionary<string,object?>decoded=Qs.Decode("a=c");// => { "a": "c" }// Encodestringencoded=Qs.Encode(newDictionary<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:
varuri=newUri("https://example.com/search?filter%5Bwhere%5D%5Bname%5D=John%20Doe&tag=a&tag=b&flag&empty=#results");varquery=uri.DecodeQsQuery(newDecodeOptions{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:
varvalues=newDictionary<string,object?>{["page"]="2"};varencoded=Qs.Encode(values);varuri=newUri($"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",newDecodeOptions{Depth=1});// => { "a": { "b": { "[c][d][e][f][g][h][i]": "j" } } }Qs.Decode("a=b&c=d",newDecodeOptions{ParameterLimit=1});// => { "a": "b" }Qs.Decode("?a=b&c=d",newDecodeOptions{IgnoreQueryPrefix=true});// => { "a": "b", "c": "d" }Qs.Decode("a=b;c=d",newDecodeOptions{Delimiter=newStringDelimiter(";")});// => { "a": "b", "c": "d" }Qs.Decode("a=b;c=d",newDecodeOptions{Delimiter=newRegexDelimiter("[;,]")});// => { "a": "b", "c": "d" }Qs.Decode("a.b=c",newDecodeOptions{AllowDots=true});// => { "a": { "b": "c" } }Qs.Decode("name%252Eobj.first=John&name%252Eobj.last=Doe",newDecodeOptions{DecodeDotInKeys=true});// => { "name.obj": { "first": "John", "last": "Doe" } }Qs.Decode("foo[]&bar=baz",newDecodeOptions{AllowEmptyLists=true});// => { "foo": [], "bar": "baz" }Qs.Decode("foo=bar&foo=baz");// => { "foo": ["bar", "baz"] }Qs.Decode("foo=bar&foo=baz",newDecodeOptions{Duplicates=Duplicates.Combine});// => same as aboveQs.Decode("foo=bar&foo=baz",newDecodeOptions{Duplicates=Duplicates.First});// => { "foo": "bar" }Qs.Decode("foo=bar&foo=baz",newDecodeOptions{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",newDecodeOptions{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",newDecodeOptions{StrictMerge=false});// => { "a": { "b": "c", "d": true } }// Latin1Qs.Decode("a=%A7",newDecodeOptions{Charset=Encoding.Latin1});// => { "a": "§" }// SentinelsQs.Decode("utf8=%E2%9C%93&a=%C3%B8",newDecodeOptions{Charset=Encoding.Latin1,CharsetSentinel=true});// => { "a": "ø" }Qs.Decode("utf8=%26%2310003%3B&a=%F8",newDecodeOptions{Charset=Encoding.UTF8,CharsetSentinel=true});// => { "a": "ø" }Qs.Decode("a=%26%239786%3B",newDecodeOptions{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. Overflow conversion
preserves every value in a numeric-keyed dictionary. List parsing is disabled
only when ParseLists is false; top-level parameter count does not change it.
Large indices convert to a dictionary by default:
Qs.Decode("a[100]=b");// => { "a": { 100: "b" } }Disable list parsing:
Qs.Decode("a[]=b",newDecodeOptions{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",newDecodeOptions{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(newDictionary<string,object?>{["a"]="b"});// => "a=b"Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]="c"}});// => "a%5Bb%5D=c"Disable URI encoding for readability:
Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]="c"}},newEncodeOptions{Encode=false});// => "a[b]=c"Values-only encoding:
Qs.Encode(newDictionary<string,object?>{["a"]="b",["c"]=newList<object?>{"d","e=f"},["f"]=newList<object?>{newList<object?>{"g"},newList<object?>{"h"},},},newEncodeOptions{EncodeValuesOnly=true});// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"Custom encoder:
Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]="č"},},newEncodeOptions{Encoder=(str,_,_)=>str?.ToString()=="č"?"c":str?.ToString()??"",});// => "a[b]=c"vardata=newDictionary<string,object?>{["a"]=newList<object?>{"b","c"}};varoptions=newEncodeOptions{Encode=false};// default (indices)Qs.Encode(data,options.CopyWith(listFormat:ListFormat.Indices));// => "a[0]=b&a[1]=c"// bracketsQs.Encode(data,options.CopyWith(listFormat:ListFormat.Brackets));// => "a[]=b&a[]=c"// repeatQs.Encode(data,options.CopyWith(listFormat:ListFormat.Repeat));// => "a=b&a=c"// commaQs.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(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]=newDictionary<string,object?>{["c"]="d",["e"]="f"},},},newEncodeOptions{Encode=false});// => "a[b][c]=d&a[b][e]=f"Dot notation:
Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]=newDictionary<string,object?>{["c"]="d",["e"]="f"},},},newEncodeOptions{Encode=false,AllowDots=true});// => "a.b.c=d&a.b.e=f"Encode dots in keys:
Qs.Encode(newDictionary<string,object?>{["name.obj"]=newDictionary<string,object?>{["first"]="John",["last"]="Doe",},},newEncodeOptions{AllowDots=true,EncodeDotInKeys=true});// => "name%252Eobj.first=John&name%252Eobj.last=Doe"Allow empty lists:
Qs.Encode(newDictionary<string,object?>{["foo"]=newList<object?>(),["bar"]="baz"},newEncodeOptions{Encode=false,AllowEmptyLists=true});// => "foo[]&bar=baz"Empty strings and nulls:
Qs.Encode(newDictionary<string,object?>{["a"]=""});// => "a="Return empty string for empty containers:
Qs.Encode(newDictionary<string,object?>{["a"]=newList<object?>()});// => ""Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>()});// => ""Qs.Encode(newDictionary<string,object?>{["a"]=newList<object?>{newDictionary<string,object?>()}});// => ""Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]=newList<object?>()}});// => ""Qs.Encode(newDictionary<string,object?>{["a"]=newDictionary<string,object?>{["b"]=newDictionary<string,object?>()}});// => ""Omit Undefined:
Qs.Encode(newDictionary<string,object?>{["a"]=null,["b"]=Undefined.Create()});// => "a="Add query prefix:
Qs.Encode(newDictionary<string,object?>{["a"]="b",["c"]="d"},newEncodeOptions{AddQueryPrefix=true});// => "?a=b&c=d"Custom delimiter:
Qs.Encode(newDictionary<string,object?>{["a"]="b",["c"]="d"},newEncodeOptions{Delimiter=";"});// => "a=b;c=d"By default, DateTime is serialized using ToString() in ISO 8601 format.
vardate=newDateTime(1970,1,1,0,0,0,7,DateTimeKind.Utc);Qs.Encode(newDictionary<string,object?>{["a"]=date},newEncodeOptions{Encode=false});// => "a=1970-01-01T00:00:00.0070000Z"Qs.Encode(newDictionary<string,object?>{["a"]=date},newEncodeOptions{Encode=false,DateSerializer= d =>((DateTimeOffset)d).ToUnixTimeMilliseconds().ToString(),});// => "a=7"// Sort keysQs.Encode(newDictionary<string,object?>{["a"]="c",["z"]="y",["b"]="f",},newEncodeOptions{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)varepochStart=newDateTime(1970,1,1,0,0,0,DateTimeKind.Utc);vartestDate=epochStart.AddMilliseconds(123);Qs.Encode(newDictionary<string,object?>{["a"]="b",["c"]="d",["e"]=newDictionary<string,object?>{["f"]=testDate,["g"]=newList<object?>{2},},},newEncodeOptions{Encode=false,Filter=newFunctionFilter((prefix,value)=>prefixswitch{"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/indicesQs.Encode(newDictionary<string,object?>{["a"]="b",["c"]="d",["e"]="f",},newEncodeOptions{Encode=false,Filter=newIterableFilter(newList<object>{"a","e"}),});// => "a=b&e=f"Qs.Encode(newDictionary<string,object?>{["a"]=newList<object?>{"b","c","d"},["e"]="f",},newEncodeOptions{Encode=false,Filter=newIterableFilter(newList<object>{"a",0,2}),});// => "a[0]=b&a[2]=d"// Treat null values like empty strings by defaultQs.Encode(newDictionary<string,object?>{["a"]=null,["b"]=""});// => "a=&b="// Cannot distinguish between parameters with and without equal signsQs.Decode("a&b=");// => { "a": "", "b": "" }// Distinguish between null values and empty strings using strict null handlingQs.Encode(newDictionary<string,object?>{["a"]=null,["b"]=""},newEncodeOptions{StrictNullHandling=true});// => "a&b="// Decode values without equals back to null using strict null handlingQs.Decode("a&b=",newDecodeOptions{StrictNullHandling=true});// => { "a": null, "b": "" }// Completely skip rendering keys with null values using skip nullsQs.Encode(newDictionary<string,object?>{["a"]="b",["c"]=null},newEncodeOptions{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:usingSystem.Text;Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);varlatin1=Encoding.GetEncoding("iso-8859-1");
// Encode using Latin1 charsetQs.Encode(newDictionary<string,object?>{["æ"]="æ"},newEncodeOptions{Charset=Encoding.Latin1});// => "%E6=%E6"// Convert characters that don't exist in Latin1 to numeric entitiesQs.Encode(newDictionary<string,object?>{["a"]="☺"},newEncodeOptions{Charset=Encoding.Latin1});// => "a=%26%239786%3B"// Announce charset using charset sentinel option with UTF-8Qs.Encode(newDictionary<string,object?>{["a"]="☺"},newEncodeOptions{CharsetSentinel=true});// => "utf8=%E2%9C%93&a=%E2%98%BA"// Announce charset using charset sentinel option with Latin1Qs.Encode(newDictionary<string,object?>{["a"]="æ"},newEncodeOptions{Charset=Encoding.Latin1,CharsetSentinel=true});// => "utf8=%26%2310003%3B&a=%E6"Qs.Encode(newDictionary<string,object?>{["a"]="b c"});// => "a=b%20c" (RFC 3986 default)Qs.Encode(newDictionary<string,object?>{["a"]="b c"},newEncodeOptions{Format=Format.Rfc3986});// => "a=b%20c"Qs.Encode(newDictionary<string,object?>{["a"]="b c"},newEncodeOptions{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
