Skip to content

QsNet

QsNet

A query string encoding and decoding library for C#/.NET.

Ported from qs for JavaScript.

TargetsDocFX DocsNuGet VersionNuGet DownloadsTestcodecovCodacy BadgeGitHubGitHub Repo stars


Highlights

  • 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 DateTime serialization via a pluggable serializer
  • Extensive tests (xUnit + FluentAssertions), performance-minded implementation

Installation

NuGet Package Manager

Install-Package QsNet

.NET CLI

dotnet add package QsNet

Package Reference

<PackageReferenceInclude="QsNet"Version="<version>"/>

Optional integrations

Core QsNet stays framework-agnostic. Install an adapter package only when you need integration with a specific HTTP or URL library.

PackageUse whenInstall
QsNet.AspNetCoreYou want ASP.NET Core helpers for parsing or appending qs-style query strings.dotnet add package QsNet.AspNetCore
QsNet.FlurlYou build URLs with Flurl and want qs-style nested query parameters.dotnet add package QsNet.Flurl
QsNet.RefitYou use Refit and need to pass a QsNet-generated query string through a request interface.dotnet add package QsNet.Refit
QsNet.RestSharpYou 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.


Requirements

  • Target frameworks (TFMs):net10.0, netstandard2.0
  • Supported runtimes (via the target frameworks above):
RuntimeVersionCI CoverageStatus
.NET10 (LTS)Full CISupported
.NET9 (STS)Consumer smoke testSupported
.NET8 (LTS)Consumer smoke testSupported
.NET7Consumer smoke testSupported
.NET6 (LTS)Consumer smoke testSupported
.NET5Optional smoke (non-blocking)EOL
.NET Core3.1Compile-only smokeEOL
.NET Framework4.6.1+Smoke test (4.6.1, 4.8.1)Supported
  • Platforms: Windows, Linux, macOS (cross-platform, no native dependencies)

Quick start

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"

ASP.NET Core helpers

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.

Flurl helpers

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.

Refit helpers

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.

RestEase without an adapter package

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.

RestSharp helpers

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.


Usage

Simple

// DecodeDictionary<string,object?>decoded=Qs.Decode("a=c");// => { "a": "c" }// Encodestringencoded=Qs.Encode(newDictionary<string,object?>{["a"]="c"});// => "a=c"

Decoding

URI queries

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.

Nested dictionaries

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" } } }

Depth (default: 5)

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" } } }

Parameter limit

Qs.Decode("a=b&c=d",newDecodeOptions{ParameterLimit=1});// => { "a": "b" }

Ignore leading ?

Qs.Decode("?a=b&c=d",newDecodeOptions{IgnoreQueryPrefix=true});// => { "a": "b", "c": "d" }

Custom delimiter (string or regex)

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" }

Dot-notation and "decode dots in keys"

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" } }

Empty lists

Qs.Decode("foo[]&bar=baz",newDecodeOptions{AllowEmptyLists=true});// => { "foo": [], "bar": "baz" }

Duplicates

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"] }

Strict merge

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

Charset and sentinel

// 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": "ø" }

Interpret numeric entities (&#1234;)

Qs.Decode("a=%26%239786%3B",newDecodeOptions{Charset=Encoding.Latin1,InterpretNumericEntities=true});// => { "a": "☺" }

Lists

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"] }

Primitive/scalar values

All values decode as strings by default:

Qs.Decode("a=15&b=true&c=null");// => { "a": "15", "b": "true", "c": "null" }

Encoding

Basics

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"

List formats

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).

Nested dictionaries

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"

Dates

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"

Sorting & filtering

// 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"

Null handling

// 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"

Charset handling

Note (Latin-1 on older TFMs): Some frameworks (e.g., netstandard2.0) don’t expose Encoding.Latin1 directly. Use Encoding.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"

RFC 3986 vs RFC 1738 space encoding

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"

Design notes

  • 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.

Other ports

PortRepositoryPackage
Darttechouse/qspub.dev
Pythontechouse/qs_codecPyPI
Kotlin / JVM + Android AARtechouse/qs-kotlinMaven Central
Swift / Objective-Ctechouse/qs-swiftSPM
Rusttechouse/qs_rustcrates.io
Node.js (original)ljharb/qsnpm

Special thanks to the authors of qs for JavaScript:


License

BSD 3-Clause © techouse

About

A query string encoding and decoding library for C#/.NET. Ported from qs for JavaScript.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages