QsNet 1.0.1

dotnet add package QsNet --version 1.0.1
                    
NuGet\Install-Package QsNet -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="QsNet" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="QsNet" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="QsNet" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add QsNet --version 1.0.1
                    
#r "nuget: QsNet, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package QsNet@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=QsNet&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=QsNet&version=1.0.1
                    
Install as a Cake Tool

QsNet

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

Ported from qs for JavaScript.

NuGet Version Test codecov GitHub GitHub 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

<PackageReference Include="QsNet" Version="<version>" />

Requirements

  • .NET 8.0+

Quick start

using QsNet;

// Decode
var obj = Qs.Decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b");
// -> { "foo": { "bar": "baz", "list": ["a", "b"] } }

// Encode
var qs = Qs.Encode(new Dictionary<string, object?> 
{ 
    ["foo"] = new Dictionary<string, object?> { ["bar"] = "baz" } 
});
// -> "foo%5Bbar%5D=baz"

Usage

Simple

// Decode
var decoded = Qs.Decode("a=c");
// => { "a": "c" }

// Encode
var encoded = Qs.Encode(new Dictionary<string, object?> { ["a"] = "c" });
// => "a=c"

Decoding

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", new DecodeOptions { Depth = 1 });
// => { "a": { "b": { "[c][d][e][f][g][h][i]": "j" } } }

Parameter limit

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

Ignore leading ?

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

Custom delimiter (string or regex)

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

Dot-notation and "decode dots in keys"

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

Empty lists

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

Duplicates

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

Charset and sentinel

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

Interpret numeric entities (&#1234;)

Qs.Decode(
    "a=%26%239786%3B",
    new DecodeOptions { 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"] }

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

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

List formats

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"

Nested dictionaries

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"

Dates

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"

Sorting & filtering

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

Null handling

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

Charset handling

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

RFC 3986 vs RFC 1738 space encoding

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"

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.

Special thanks to the authors of qs for JavaScript:


License

BSD 3-Clause © techouse

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 119 8/8/2025
1.0.0 145 8/7/2025