In this post, we are going to learn how to write a JSON into a file in C#.

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

JSON is structured, lightweight, widely supported, more readable, and more efficient than its XML counterpart. That’s why, many applications nowadays prefer JSON for storing structured data, especially metadata such as configurations, user preferences, and so on. .NET applications are no different from that. As such, we see .NET leaning to JSON-based configuration files like appSettings.json, launchSettings.json, etc.

We are going to explore different ways of writing JSON data to a file using the inbuilt System.Text.Json library and the popular Newtonsoft.Json library.

Let’s get to work.

Preparation of Object Data Source

Let’s assume we want to write a survey result of neighborhood colleges in JSON format. We can start with some building blocks in our class library project.

The College record:

// College.cs
public record class College(
    string Name, 
    int NoOfStudents, 
    bool IsPublic);

And the SurveyReport class:

public static class SurveyReport 
{ 
    public static List<College> GetColleges() => new() 
    { 
        new("Juvenile", 300, false), 
        new("Cambrian", 650, true), 
        new("Mapple Leaf", 450, true) 
    }; 
}

Now, let’s continue.

Write JSON File: Simplistic Approach

We are going to add two utility classes, one for each target JSON library, to hold our JSON-writing routines:

// Native/JsonFileUtils.cs
public static class JsonFileUtils
{
    private static readonly JsonSerializerOptions _options = 
        new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
    
    public static void SimpleWrite(object obj, string fileName)
    {
        var jsonString = JsonSerializer.Serialize(obj, _options);

        File.WriteAllText(fileName, jsonString);
    }
}

// Newtonsoft/JsonFileUtils.cs
public static class JsonFileUtils
{
    private static readonly JsonSerializerSettings _options
        = new() { NullValueHandling = NullValueHandling.Ignore };
    
    public static void SimpleWrite(object obj, string fileName)
    {
        var jsonString = JsonConvert.SerializeObject(obj, _options);

        File.WriteAllText(fileName, jsonString);
    }
}

Right at the top of each class, we configure a JsonSerializerOptions options (JsonSerializerSettings for Newtonsoft) for ignoring null values. We will use this _options to produce a clean JSON output.

Straightaway, we go for the first quick implementation: SimpleWrite. First, we serialize the object to a string using JsonSerializer.Serialize method for the native version and JsonConvert.SerializeObject for Newtonsoft. Then, we write this string to file using File.WriteAllText. As simple as that.

After that, we can call this SimpleWrite method with the college list:

var colleges = SurveyReport.GetColleges();
var fileName = "Colleges.json";
JsonFileUtils.SimpleWrite(colleges, fileName);

This creates a Colleges.json file in the output directory that contains the resulting JSON:

[{"Name":"Juvenile","NoOfStudents":300,"IsPublic":false},{"Name":"Cambrian","NoOfStudents":650,"IsPublic":true},{"Name":"Mapple Leaf","NoOfStudents":450,"IsPublic":true}]

That’s the output we desire. But, this does not look readable, right?

Write JSON File with Better Readability

So, we want a better readable output file:

// Native/JsonFileUtils.cs
public static void PrettyWrite(object obj, string fileName)
{
    var options = new JsonSerializerOptions(_options) 
    { 
        WriteIndented = true
    };
    var jsonString = JsonSerializer.Serialize(obj, options);

    File.WriteAllText(fileName, jsonString);
}

// Newtonsoft/JsonFileUtils.cs
public static void PrettyWrite(object obj, string fileName)
{
    var jsonString = JsonConvert.SerializeObject(obj, Formatting.Indented, _options);

    File.WriteAllText(fileName, jsonString);
}

Here, we can see the pretty-print variant of our previous routine.

For the native version, we instantiate a JsonSerializerOptions with its WriteIndented flag enabled. Then we call the appropriate Serialize overload. In contrast, Newtonsoft’s  SerializeObject directly accepts Formatting.Indented option.

Once we use this PrettyWrite method:

var colleges = SurveyReport.GetColleges();
var fileName = "Colleges-pretty.json";
JsonFileUtils.PrettyWrite(colleges, fileName);

 We get a nicely formatted JSON file:

[
  {
    "Name": "Juvenile",
    "NoOfStudents": 300,
    "IsPublic": false
  },
  {
    "Name": "Cambrian",
    "NoOfStudents": 650,
    "IsPublic": true
  },
  {
    "Name": "Mapple Leaf",
    "NoOfStudents": 450,
    "IsPublic": true
  }
]

Nice! The content is greatly readable now!

However, we should avoid pretty printing for the production code. The fact is it generates a lot of extra white spaces that affect the performance and the bandwidth. Apart from that, “readability” is purely a display capability that should not generally dictate “storage” responsibility. This should be the responsibility of the presenter program that we usually manage by a browser plugin or editor setting. 

Write JSON File: Efficient Approach

We now have a basic JSON-writing routine which is inefficient for several reasons:

  • Serializes to a string before writing to the file, causing performance cost for UTF-16 conversion
  • Buffers the full output in a local copy causing substantial memory overhead
  • Expensive I/O operation blocking the running thread

Let’s explore the options to handle these bottlenecks one by one. 

Writing UTF-8 Bytes Instead of UTF-16 String

First and foremost, we want to avoid the expensive string (UTF-16) conversion. We can serialize to UTF-8 bytes instead, which is about 5-10% faster than using the string-based methods. The difference is because we don’t need to convert bytes (as UTF-8) to strings (UTF-16):

// Native/JsonFileUtils.cs
public static void Utf8BytesWrite(object obj, string fileName)
{
    var utf8Bytes = JsonSerializer.SerializeToUtf8Bytes(obj, _options);
    File.WriteAllBytes(fileName, utf8Bytes);
}

We come up with the Utf8BytesWrite method in two steps: get serialized output directly in bytes and write the file accordingly.

With the native library, this is just a two-liner code. It provides the JsonSerializer.SerializeToUtf8Bytes method to get byte output directly.

Newtonsoft, on the other hand, does not offer any direct way to do this. But, we can implement our own version of the SerializeToUtf8Bytes method:

// Newtonsoft/JsonFileUtils.cs
static byte[] SerializeToUtf8Bytes(object obj, JsonSerializerSettings options)
{
    using var stream = new MemoryStream();
    using var streamWriter = new StreamWriter(stream); 
    using var jsonWriter = new JsonTextWriter(streamWriter);

    JsonSerializer.CreateDefault(options).Serialize(jsonWriter, obj);

    jsonWriter.Flush();
    stream.Position = 0;
    return stream.ToArray();
}

We start with a MemoryStream instance (stream) just like a typical byte-output routine does.

What comes next is to find a stream-supported serializer routine. Unlike our previous examples, this time we don’t get help from JsonConvert. Instead, JsonSerializer class is the rescuer here. This offers a Serialize method that works on a StreamWriter/TextWriter or a JsonTextWriter object. So, we create a StreamWriter from the stream, wrap it within a JsonTextWriter instance and call the Serialize method accordingly. We can directly use the StreamWriter instance but the JsonTextWriter makes it efficient.

Subsequently, we ensure the completion of writing by calling Flush and then reset the stream position to get the full byte array. That’s it.

We can now implement the Newtonsoft version of the Utf8BytesWrite method just like the native version:

// Newtonsoft/JsonFileUtils.cs
public static void Utf8BytesWrite(object obj, string fileName)
{
    var utf8Bytes = SerializeToUtf8Bytes(obj, _options);
    File.WriteAllBytes(fileName, utf8Bytes);
}

Writing JSON with this Utf8BytesWrite method generates the output faster and we will see that in our performance section of this article.

That said, we have an even better way to deal with this.

Write Directly to FileStream

We don’t want to eat up the memory holding the full output in a local copy. It can be disastrous if we have a large object graph. We don’t need that actually. Both libraries offer a serializing method that writes directly to the file stream: 

// Native/JsonFileUtils.cs
public static void StreamWrite(object obj, string fileName)
{
    using var fileStream = File.Create(fileName);
    using var utf8JsonWriter = new Utf8JsonWriter(fileStream);

    JsonSerializer.Serialize(utf8JsonWriter, obj, _options);
}

// Newtonsoft/JsonFileUtils.cs
public static void StreamWrite(object obj, string fileName)
{
    using var streamWriter = File.CreateText(fileName);
    using var jsonWriter = new JsonTextWriter(streamWriter);

    JsonSerializer.CreateDefault(_options).Serialize(jsonWriter, obj);
}

StreamWrite is the method that we create to directly write to a file stream.

Talking about the native version, we create a FileStream using File.Create method, wrap it in an Utf8JsonWriter instance and call the appropriate Serialize method of JsonSerializer. We can also directly use the FileStream object, but the Utf8JsonWriter provides the most efficient operation.

For Newtonsoft, we already know how to work with a stream-writer. This time we don’t need to handle the raw stream directly. We create a StreamWriter using File.CreateText, wrap it within a JsonTextWriter instance, and call the Serialize method like before.   

The best thing about this StreamWrite version is, this solves our first two problems simultaneously.

Asynchronous Writing

We’re not done yet. As mentioned before, we should not block the running thread keeping it waiting for an expensive I/O operation to complete. While this does not matter for the performance of the JSON-write operation itself, it does matter for the responsiveness and/or scalability of the caller program. So, we need an asynchronous version:

public static async Task StreamWriteAsync(object obj, string fileName)
{
    await using var fileStream = File.Create(fileName);
    await using var utf8JsonWriter = new Utf8JsonWriter(fileStream);

    await JsonSerializer.SerializeAsync(utf8JsonWriter, obj, _options);
}

This is very similar to the synchronous StreamWrite version. Thanks to the native JsonSerializer.SerializeAsync method that makes it possible. The rest part is typical async method semantics. Both FileStream and Utf8JsonWriter constructions are awaitable. So, in our case, all three lines can be marked await.  Finally, we adjust the method signature as an async Task. That’s all.

Unfortunately, NewtonsoftJson doesn’t support asynchronous serialization out of the box yet. What we can do at best is offload this I/O operation to thread-pool: 

public static async Task StreamWriteAsync(object obj, string fileName)
{
    await Task.Run(() => StreamWrite(obj, fileName));
}

We simply wrap the synchronous version in Task.Run construct and await the returned task instance. This is not a proper asynchronous operation and does not scale up in terms of thread consumption. But in our case, this at least serves the purpose of non-blocking I/O operation.

Write Mutable JSON Object to File

So far, we’ve seen how to write JSON from POCO objects. What about writing a mutable JSON object e.g. JsonObject (native) or JObject (Newtonsoft)?

They’re useful means of making up dynamic JSON data on demand. A typical use-case is: we wrap raw JSON string coming from an external source in a mutable JSON object, customize as we need, and then generate modified JSON output. The good thing is, these classes provide a WriteTo method that supports direct stream-writing:

// Native/JsonFileUtils.cs
public static void WriteDynamicJsonObject(JsonObject jsonObj, string fileName)
{
    using var fileStream = File.Create(fileName);
    using var utf8JsonWriter = new Utf8JsonWriter(fileStream);

    jsonObj.WriteTo(utf8JsonWriter);
}

// Newtonsoft/JsonFileUtils.cs
public static void WriteDynamicJsonObject(JObject jsonObj, string fileName)
{
    using var streamWriter = File.CreateText(fileName);
    using var jsonWriter = new JsonTextWriter(streamWriter);

    jsonObj.WriteTo(jsonWriter);
}

Once again, we build a helper routine in a few simple steps. Inside the method, we create the relevant JSON writer as usual and invoke the WriteTo method with the writer instance. That’s it.

When we try this method on a dynamic JSON object:

// NativeJsonFileUtilsUnitTest.cs
var fileName = "Dynamic-json.json";
var jsonObj = new JsonObject
{
    ["Name"] = "SunnyDale",
    ["NoOfStudents"] = 200,
    ["IsPublic"] = true
};

JsonFileUtils.WriteDynamicJsonObject(jsonObj, fileName);

// NewtonsoftJsonFileUtilsUnitTest.cs
var fileName = "Dynamic-json.json";
var jsonObj = new JObject
{
    ["Name"] = "SunnyDale",
    ["NoOfStudents"] = 200,
    ["IsPublic"] = true
};

JsonFileUtils.WriteDynamicJsonObject(jsonObj, fileName);

We get a JSON file as expected:

{"Name":"SunnyDale","NoOfStudents":200,"IsPublic":true}

Performance Comparison of Different Variants

We now have different variants of JSON-writing routines. It’s time for benchmarking these methods.

Prepare Complex Object Graph

So far, we work with a very simple data structure that can merely produce any difference in performance. Let’s introduce some layers of nesting there:

// College.cs
public record class College(
    string Name, 
    int NoOfStudents, 
    bool IsPublic, 
    List<Teacher>? Teachers = null);

public record class Teacher(
    string Name, 
    int WorkHours, 
    bool InProbation, 
    List<Course>? Courses = null);

public record class Course(
    string Name, 
    int CreditHours, 
    bool IsOptional);

First, we modify the College record to include a property for List<Teacher>. Teacher is the 2nd tier of data in our object graph. This model contains three basic properties and property for List<Course>. Course is the 3rd data-tier in the graph having only three basic properties.

Next, we enrich our SurveyReport class with another data-provider method:

public static IEnumerable<College> GetColleges(
    int noOfColleges, 
    int teachersPerCollege, 
    int coursesPerTeacher)
{
    for (var i = 0; i < noOfColleges; i++)
    {
        yield return new($"College{i}", 100, true, GetTeachers());
    }

    List<Teacher>? GetTeachers()
        => Enumerable.Repeat<Teacher>(new($"John", 8, false, GetCourses()), teachersPerCollege).ToList();        

    List<Course>? GetCourses()
        => Enumerable.Repeat<Course>(new($"Course", 4, false), coursesPerTeacher).ToList();
}

This method generates data for a batch of colleges including teachers and their assigned courses.

Benchmark Analysis

Once we run the benchmark on a dataset of 500 colleges, 25 teachers per college, and 3 courses per teacher (nearly 50K records in total), we can inspect our result:

|              Method      |     Mean |    Error |   StdDev | Ratio | RatioSD |
|------------------------- |---------:|---------:|---------:|------:|--------:|
|              StreamWrite | 845.1 ms |  3.81 ms |  3.38 ms |  0.98 |    0.01 |
|           Utf8BytesWrite | 849.7 ms |  6.10 ms |  5.40 ms |  0.99 |    0.01 |
|              SimpleWrite | 861.6 ms |  6.76 ms |  5.64 ms |  1.00 |    0.00 |
|              PrettyWrite | 921.6 ms | 13.92 ms | 13.02 ms |  1.07 |    0.01 |
|                          |          |          |          |       |         |
|    NewtonsoftStreamWrite | 869.3 ms |  9.20 ms |  7.68 ms |  0.98 |    0.01 |
| NewtonsoftUtf8BytesWrite | 871.8 ms | 10.89 ms | 10.19 ms |  0.98 |    0.00 |
|    NewtonsoftSimpleWrite | 883.7 ms | 17.32 ms | 17.01 ms |  1.00 |    0.03 |
|    NewtonsoftPrettyWrite | 970.4 ms | 17.06 ms | 15.12 ms |  1.10 |    0.01 |

Our two key findings here: direct stream writing (StreamWrite) is the fastest process and System.Text.Json outperforms Newtonsoft.Json in all cases.

We can anticipate that these performance gaps grow higher with the larger dataset.

Overall, we should go with the direct file-writing method and avoid pretty writing in general.

Conclusion

In this article, we have learned a few ways to write a JSON file in C#. We have also discussed some performance factors along with benchmarking analysis.