Updated on

To serialize a dictionary to JSON in C#, we pass it to JsonSerializer.Serialize() from System.Text.Json or to JsonConvert.SerializeObject() from Newtonsoft.Json. Each key becomes a property name and each value becomes that property’s value.

That works out of the box for string keys and a handful of simple types. When the key is an object of our own, the two libraries part ways, and that’s where most of this article goes. We’ll use both Newtonsoft.Json and System.Text.Json throughout.

To download the source code for this article, you can visit our GitHub repository.

So, let’s start.

How Do We Serialize a Simple Dictionary to JSON?

A dictionary serializes to a JSON object. Every key becomes a property name, and every value is serialized the same way it would be anywhere else.

With System.Text.Json, which ships with .NET, the call is JsonSerializer.Serialize(dictionary). With Newtonsoft.Json, a NuGet package, it’s JsonConvert.SerializeObject(dictionary). For our fruit inventory, a Dictionary<string, int>, both return the same string: {"apple":3,"orange":5,"banana":7}.

JSON property names are always strings, so every key has to end up as one. A string key is written as it is. An int, Guid, DateTime or enum key is written as its text form, so a Dictionary<int, string> comes out as {"1":"one","2":"two"}.

Neither library indents its output by default, which makes bigger dictionaries hard to read. We’ll fix that in the next section.

Reading the JSON back works with the same two libraries. JsonSerializer.Deserialize<Dictionary<string, int>>(json) turns the string into a dictionary again.

Let’s begin by adding the Newtonsoft.Json package to a console app with dotnet add package Newtonsoft.Json and defining a very simple Dictionary containing an inventory of fruit:

using Newtonsoft.Json;
using System.Text.Json;
using JsonSerializer = System.Text.Json.JsonSerializer; // both libraries have a JsonSerializer class

var fruitInventory = new Dictionary<string, int>
{
    {"apple", 3},
    {"orange", 5},
    {"banana", 7}
};

Now let’s serialize it to JSON:

JsonConvert.SerializeObject(fruitInventory); // Newtonsoft

JsonSerializer.Serialize(fruitInventory); // System.Text.Json

Here we notice how simple it is to serialize an object using either of the two libraries. Both of them allow us to serialize our dictionary to JSON with just a single line of code.

Now let’s look at the output:

============ Newtonsoft.Json ============
{"apple":3,"orange":5,"banana":7}

============ System.Text.Json ============
{"apple":3,"orange":5,"banana":7}

Both libraries serialize our simple dictionary to JSON, but due to the lack of whitespace, we may find the output a little hard to read. 

Formatting the Output for Readability

While it is great that we can quickly and easily serialize a dictionary with just a single line of code, if we are planning to produce output that will be human-readable, we need to find a way to adjust our output format a bit. Thankfully both libraries provide a mechanism to override the default output settings:

JsonConvert.SerializeObject(fruitInventory, Formatting.Indented);

JsonSerializer.Serialize(fruitInventory, new JsonSerializerOptions
{
    WriteIndented = true
});

We saw that both libraries allow for serialization with a single line of code, but here things have changed a little. The Newtonsoft.Json still allows for serialization with a single line due to the method overload which takes a Formatting enum parameter. System.Text.Json, on the other hand, requires us to specify a JsonSerializerOptions object in which we set the WriteIndented property to true.

Now let’s take a look at the updated output:

============ Newtonsoft.Json - Indented ============
{
  "apple": 3,
  "orange": 5,
  "banana": 7
}

============ System.Text.Json - Indented ============
{
  "apple": 3,
  "orange": 5,
  "banana": 7
}

Here we see output that is far easier to read due to the indenting and additional whitespace.

Indentation isn’t the only thing we might want to change. What about the case of the keys? With System.Text.Json, PropertyNamingPolicy doesn’t touch dictionary keys. Keys have their own setting, DictionaryKeyPolicy:

var stockByProduct = new Dictionary<string, int>
{
    {"AppleCount", 3},
    {"OrangeCount", 5}
};

JsonSerializer.Serialize(stockByProduct, new JsonSerializerOptions
{
    DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
});

This time the output is {"appleCount":3,"orangeCount":5}. Microsoft Learn adds one limit: “Naming policies for dictionary keys apply to serialization only.” When we read the JSON back, the keys stay exactly as they are in the file.

Newtonsoft.Json works the other way around. Its CamelCasePropertyNamesContractResolver changes dictionary keys along with property names, which can surprise us when the keys are data rather than names. For writing a naming policy of our own, check out our article on custom naming policies for JSON.

How Do We Serialize a Dictionary With Complex Keys?

When the key is one of our own types, the two libraries behave differently, and neither does what we usually want.

Newtonsoft.Json turns the key into a string by calling its ToString() method, unless the type has a TypeConverter. For a record, ToString() prints every public property, so the whole customer ends up crammed into one property name.

System.Text.Json refuses. It supports a fixed list of key types, such as string, the numeric types, Guid, DateTime and enums, and it throws a NotSupportedException for anything else.

There are three ways out. We can write a converter for the whole dictionary and decide exactly what each entry looks like, which is what this section does with both libraries. We can write a converter for the key type alone. Or we can project the dictionary to simple keys with ToDictionary() before serializing it.

The first option gives us the most control, and the last one needs the least code.

We have already seen how to serialize a simple dictionary, but what happens when our dictionary contains more complex objects? To explore this scenario let’s imagine that we have been tasked with adding a new method to our REST API that returns a collection of customers mapped to a list of their invoices. Now imagine that within our code we already have just such a dictionary where the keys are Customer objects and the value is a list of Invoice objects. This is a perfect situation in which we would want to serialize a dictionary to JSON.

To explore this example, first, we need to create some records:

public sealed record Person(string FirstName, string LastName)
{
    public override string ToString() => (FirstName + " " + LastName).Trim();
}

public sealed record Customer(string Address, string PhoneNumber, Person Client, Guid CustomerId);

public sealed record Product(string Name, decimal CostPerItem, Guid ProductId);

public sealed record InvoiceLineItem(Product Item, int Quantity);

public sealed record Invoice(DateTime InvoiceDate, Guid InvoiceId, List<InvoiceLineItem> Items);

Now let’s create some sample data and populate a Dictionary. The method goes inside a static Helper class:

public static Dictionary<Customer, List<Invoice>> GenerateSampleInvoices()
{
    var customers = new[]
    {
        new Customer(
            "123 Nowhere Street, Fakeville, MD",
            "555-555-0001",
            new Person("John", "Smith"),
            Guid.NewGuid()
        ),
        // Omitted for brevity
    };

    var products = new List<Product>
    {
        new("Widget", 1.27m, Guid.NewGuid()),
        // Omitted for brevity
    };

    var invoices = new List<Invoice>
    {
        new(
            DateTime.Now.AddDays(-14),
            Guid.NewGuid(),
            new List<InvoiceLineItem>
            {
                new(products[0], 10),
                // Omitted for brevity
            }
        ),
        // Omitted for brevity
    };

    var dictionary = new Dictionary<Customer, List<Invoice>>
    {
        {customers[0], invoices.Take(2).ToList()},
        // Omitted for brevity
    };

    return dictionary;
}

Some code has been omitted for brevity, but you can find a complete listing in our source code.

Serializing a Dictionary to JSON with Newtonsoft.Json

Now let’s serialize our dictionary using Newtonsoft.Json:

var invoicesByCustomer = Helper.GenerateSampleInvoices();

JsonConvert.SerializeObject(invoicesByCustomer, Formatting.Indented);

And here is the output:

{
  "Customer { Address = 123 Nowhere Street, Fakeville, MD, PhoneNumber = 555-555-0001, 
   Client = John Smith, CustomerId = 470ebbe7-f540-43a2-bc7c-998d703fb5f1 }": [
    {
      "InvoiceDate": "2023-03-20T00:54:13.4187381+02:00",
      "InvoiceId": "7769b342-d7f9-4154-8523-c9077c2ee3f8",
      "Items": [
        {
          "Item": {
            "Name": "Widget",
            "CostPerItem": 1.27,
            "ProductId": "84ccf266-fe7e-4727-a88c-4cf869be371b"
          },
          "Quantity": 10
        },
        {
          "Item": {
            "Name": "Thing",
            "CostPerItem": 35.72,
            "ProductId": "b6b14309-4c3e-4839-ae62-41eff3449af3"
          },
          "Quantity": 10
        }
      ]
    },
   ...

Note: The remainder of the output has been removed for brevity. We will do the same throughout the rest of the article.

Once again we see that with Newtonsoft.Json we can serialize our complex object with a single line of code, but we may not be completely happy with the result. When we look at the keys for the invoice array, we see that the library uses the entire Customer as the key. That string is the output of the record’s compiler-generated ToString() method. We can solve this by implementing a custom converter:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

public sealed class NewtonsoftCustomerInvoiceConverter : JsonConverter<Dictionary<Customer, List<Invoice>>>
{
    public override void WriteJson(JsonWriter writer, Dictionary<Customer, List<Invoice>>? value,
        JsonSerializer serializer)
    {
        if (value is null) return;

        writer.WriteStartObject();

        foreach (var (customer, invoices) in value)
        {
            writer.WritePropertyName($"Customer-{customer.CustomerId:N}");

            writer.WriteStartObject();

            WriteCustomerProperties(writer, customer);

            WriteInvoices(writer, invoices);

            writer.WriteEndObject();
        }

        writer.WriteEndObject();
    }

    // Omitted for brevity
}

Our main focus is the WriteJson override that allows us to customize how our object is serialized. The omitted part holds the helper methods we look at below and a ReadJson() override that only throws NotImplementedException, since we only serialize. For more details on creating custom converters, you can check out the article Custom JsonConverter in C#: Json.NET (Newtonsoft.Json).

Let’s take a quick look at what our method is doing. First, we write out the start of our object with a call to WriteStartObject. Next, we iterate through the collection of Customer and Invoice objects, calling our helper methods to write out the appropriate properties. Finally, we close out our JSON with a final call to WriteEndObject.

Now let’s take a look at our helper methods. First, we have WriteCustomerProperties:

private static void WriteCustomerProperties(JsonWriter writer, Customer customer)
{
    writer.WritePropertyName(nameof(customer.Client));
    writer.WriteValue(customer.Client.ToString());

    writer.WritePropertyName(nameof(customer.Address));
    writer.WriteValue(customer.Address);

    writer.WritePropertyName(nameof(customer.PhoneNumber));
    writer.WriteValue(customer.PhoneNumber);
}

This method is fairly straightforward. We just have to write out the key-value pairs for each property of the customer that we wish to serialize.

Next, we have WriteInvoices:

private static void WriteInvoices(JsonWriter writer, List<Invoice> invoices)
{
    writer.WritePropertyName("Invoices");
    var jToken = JToken.FromObject(invoices);
    jToken.WriteTo(writer);
}

With this method, we can take advantage of the Newtonsoft.Json JToken class and use it to generate the JSON for our List<Invoice>.

Now that we have seen the code, let’s pass the converter to SerializeObject() and watch it in action:

JsonConvert.SerializeObject(invoicesByCustomer, Formatting.Indented, new NewtonsoftCustomerInvoiceConverter());
{
  "Customer-bfafbb48f6704e0e87f6d44ac219cf7b": {
    "Client": "John Smith",
    "Address": "123 Nowhere Street, Fakeville, MD",
    "PhoneNumber": "555-555-0001",
    "Invoices": [
      {
        "InvoiceDate": "2023-03-22T22:14:28.2126817+02:00",
        "InvoiceId": "5f14c93e-cc80-492c-9873-0426f6f5cc7f",
        "Items": [
          {
            "Item": {
              "Name": "Widget",
              "CostPerItem": 1.27,
              "ProductId": "127ed2a0-ea95-46b5-8c3a-b0e03d6380d1"
            },
            "Quantity": 10
          },
         ...

Serializing a Dictionary to JSON with System.Text.Json

Let’s go ahead and try serializing our sample data with System.Text.Json:

JsonSerializer.Serialize(invoicesByCustomer, new JsonSerializerOptions
{
    WriteIndented = true,
});

Unfortunately, running this code throws a System.NotSupportedException. So let’s examine what went wrong and how we can fix it.

On .NET 10, the exception message starts like this:

The type 'JsonSerializeToDictionaryDataModel.Customer' is not a supported dictionary key using converter of type '...'. Custom converters can add support for dictionary key serialization by overriding the 'ReadAsPropertyName' and 'WriteAsPropertyName' methods.

So how can we solve this issue? The message points at one option, and we’ll come back to it after this section. First, let’s take full control with a custom converter for the whole dictionary.

Creating a Custom Converter for System.Text.Json

We just need to create a custom converter and add it to our serializer options declaration:

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class SystemJsonCustomerInvoiceConverter : JsonConverter<Dictionary<Customer, List<Invoice>>>
{
    public override void Write(Utf8JsonWriter writer, Dictionary<Customer, List<Invoice>> value,
        JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        foreach (var (customer, invoices) in value)
        {
            writer.WritePropertyName($"Customer-{customer.CustomerId:N}");

            writer.WriteStartObject();

            WriteCustomerProperties(writer, customer);

            WriteInvoices(writer, invoices);

            writer.WriteEndObject();
        }

        writer.WriteEndObject();
    }
    // Omitted for brevity
}

This code is very similar to the custom converter we created for Newtonsoft.Json serialization, and its omitted part again holds a Read() override that only throws NotImplementedException. The syntax is slightly different, but the concept is completely the same. First, we call WriteStartObject to begin writing our object. Next, we loop through the collection of Customer and Invoice objects, calling our helper methods to serialize the related properties. Lastly, we close out our object with a call to WriteEndObject.

Now let’s take a look at our helper methods. First, WriteCustomerProperties:

private static void WriteCustomerProperties(Utf8JsonWriter writer, Customer customer)
{
    writer.WritePropertyName(nameof(customer.Client));
    writer.WriteStringValue(customer.Client.ToString());

    writer.WritePropertyName(nameof(customer.Address));
    writer.WriteStringValue(customer.Address);

    writer.WritePropertyName(nameof(customer.PhoneNumber));
    writer.WriteStringValue(customer.PhoneNumber);
}

As we would expect, our code is nearly identical to the code we wrote for our Newtonsoft custom converter.

Next up we have our WriteInvoices method:

private static void WriteInvoices(Utf8JsonWriter writer, List<Invoice> invoices)
{
    writer.WritePropertyName("Invoices");
    writer.WriteStartArray();

    foreach (var invoice in invoices) WriteInvoiceProperties(writer, invoice);

    writer.WriteEndArray();
}

This time our WriteInvoices code is not as succinct as we don’t use a helper like JToken, so we serialize the list ourselves. We start by writing our key, but because we are serializing a List we have to serialize it as an array.

Once we have started the array, we simply iterate over the items and write them out using our helper method WriteInvoiceProperties:

private static void WriteInvoiceProperties(Utf8JsonWriter writer, Invoice invoice)
{
    writer.WriteStartObject();

    writer.WritePropertyName(nameof(invoice.InvoiceDate));
    writer.WriteStringValue(invoice.InvoiceDate);

    writer.WritePropertyName(nameof(invoice.InvoiceId));
    writer.WriteStringValue(invoice.InvoiceId);

    writer.WritePropertyName(nameof(invoice.Items));
    writer.WriteStartArray();
    foreach (var lineItem in invoice.Items)
    {
        writer.WriteStartObject();

        WriteLineItem(writer, lineItem);

        writer.WriteEndObject();
    }
    writer.WriteEndArray();

    writer.WriteEndObject();
}

Since each Invoice is itself an object, we start with WriteStartObject and end with WriteEndObject. After starting our object we begin writing the key-value pairs. When we get to the Items property, however, since it is a List, we have to serialize it as an array. Once again we make use of a helper method, WriteLineItem:

private static void WriteLineItem(Utf8JsonWriter writer, InvoiceLineItem lineItem)
{
    writer.WritePropertyName("Item");

    WriteProductItem(writer, lineItem.Item);

    writer.WritePropertyName(nameof(lineItem.Quantity));
    writer.WriteNumberValue(lineItem.Quantity);
}

This code follows the same pattern we have already seen, namely writing key-value pairs. We also call one last helper method to write the Product object:

private static void WriteProductItem(Utf8JsonWriter writer, Product productItem)
{
    writer.WriteStartObject();

    writer.WritePropertyName(nameof(productItem.Name));
    writer.WriteStringValue(productItem.Name);

    writer.WritePropertyName(nameof(productItem.CostPerItem));
    writer.WriteNumberValue(productItem.CostPerItem);

    writer.WritePropertyName(nameof(productItem.ProductId));
    writer.WriteStringValue(productItem.ProductId);

    writer.WriteEndObject();
}

Now that we have our custom converter created, let’s try our serialization again:

var systemJsonComplex = JsonSerializer.Serialize(invoicesByCustomer, new JsonSerializerOptions
{
    Converters = { new SystemJsonCustomerInvoiceConverter() },
    WriteIndented = true,
});

This time our code runs without any exceptions and we see our expected output:

{
  "Customer-c6caed9431904e609e6dfdd43955bc0b": {
    "Client": "John Smith",
    "Address": "123 Nowhere Street, Fakeville, MD",
    "PhoneNumber": "555-555-0001",
    "Invoices": [
      {
        "InvoiceDate": "2023-03-22T22:16:48.1286004+02:00",
        "InvoiceId": "ee47065c-6f98-4546-a58f-b82e5460f513",
        "Items": [
          {
            "Item": {
              "Name": "Widget",
              "CostPerItem": 1.27,
              "ProductId": "eedb7fd3-b0d7-441f-9076-a789d1918311"
            },
            "Quantity": 10
          },
          ...

Which Dictionary Key Types Does System.Text.Json Support?

Microsoft Learn lists these key types as supported out of the box: string, the integer types from byte to ulong, decimal, double, float, bool, Guid, DateTime, DateTimeOffset, TimeSpan, Uri, Version and any enum. An object key also works when serializing, as long as the value inside it is one of those types. In our .NET 10 run, char, Int128, Half and DateOnly keys worked too, though the list skips them.

For a key type the serializer doesn’t know, such as our Customer, a converter for that type can override WriteAsPropertyName(). The serializer calls it for every key of that type. ReadAsPropertyName() is the matching method for reading JSON back.

Unlike the dictionary converter from the previous section, it changes only the key. The values still go through the normal serializer, so the List<Invoice> needs no code from us.

The catch is in the output: the property name is all that’s left of the customer, so this works best when the key is an identifier.

Here’s a converter for the Customer type alone:

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class CustomerKeyConverter : JsonConverter<Customer>
{
    public override Customer Read(ref Utf8JsonReader reader, Type typeToConvert,
        JsonSerializerOptions options)
    {
        // Since we are only dealing with Serializing in this article, we don't need to implement the Read method
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, Customer value, JsonSerializerOptions options)
        => writer.WriteStringValue($"Customer-{value.CustomerId:N}");

    public override void WriteAsPropertyName(Utf8JsonWriter writer, Customer value,
        JsonSerializerOptions options)
        => writer.WritePropertyName($"Customer-{value.CustomerId:N}");
}

WriteAsPropertyName() is the method that matters here. It writes the same Customer- key our dictionary converter wrote. Write() covers the case where a Customer shows up as a value, and we keep it consistent with the key.

Now we register it like any other converter:

JsonSerializer.Serialize(invoicesByCustomer, new JsonSerializerOptions
{
    Converters = { new CustomerKeyConverter() },
    WriteIndented = true
});

And here’s the output:

{
  "Customer-0b9e864b3abc48acbe8841666dcc6465": [
    {
      "InvoiceDate": "2026-10-11T13:26:47.2027085+02:00",
      "InvoiceId": "2c814fe7-f284-4b16-b386-9e28454d19b6",
      "Items": [
        {
          "Item": {
            "Name": "Widget",
            "CostPerItem": 1.27,
            "ProductId": "0e31bee2-03da-4467-9b86-725f3fa0136a"
          },
          "Quantity": 10
        },
        ...

The invoices come out exactly as the default serializer writes them, with no helper methods of ours. What we lost are the Client, Address and PhoneNumber values. If the receiving side needs those, the dictionary converter is the right tool. If it only needs to know which customer the invoices belong to, this one is a lot less code.

Is there something even simpler? If we don’t need a converter at all, we can turn the keys into a supported type before we serialize, with the LINQ ToDictionary() method:

var invoicesByCustomerId = invoicesByCustomer.ToDictionary(pair => pair.Key.CustomerId, pair => pair.Value);

JsonSerializer.Serialize(invoicesByCustomerId, new JsonSerializerOptions
{
    WriteIndented = true
});

The keys are now Guid values, which System.Text.Json writes as strings such as "0b9e864b-3abc-48ac-be88-41666dcc6465". It’s the same trade-off as the key converter, without a new class.

Conclusion

In this article, we have seen how we can serialize both a simple and complex dictionary to JSON using two of the most popular JSON libraries: Newtonsoft.Json and System.Text.Json. We have also seen how to create custom converters which enable us to have tighter control over how objects are serialized. And when only the key needs to change, a System.Text.Json converter that overrides WriteAsPropertyName() does the job with far less code.

Tested with .NET 10.0.10, System.Text.Json 10.0, and Newtonsoft.Json 13.0.4.