Use JsonSerializer.Serialize(obj) from System.Text.Json, which ships with .NET, so there is nothing to install. It returns a compact string: new { Id = 7, Tags = new[] { "a", "b" } } becomes {"Id":7,"Tags":["a","b"]}. Pass a JsonSerializerOptions for the rest: WriteIndented = true to pretty-print, a JsonStringEnumConverter for enums as strings, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to drop nulls, and ReferenceHandler.IgnoreCycles when objects point back at each other.
For years the answer to this question was Newtonsoft.Json's JsonConvert.SerializeObject. Since .NET Core 3.0 the framework has its own serializer, System.Text.Json, and it is what ASP.NET Core uses by default. It reads your object's public properties and writes them out with their C# names. Most of the follow-up questions come from its defaults: enums come out as numbers, nulls are written and a reference loop throws, and, unlike Newtonsoft, public fields are skipped and characters such as é and < are escaped. Each example runs on this page on .NET 10: hit Run, then edit the code and run it again.
1JsonSerializer.SerializeRecommended
Add using System.Text.Json; and call JsonSerializer.Serialize with any object: a record or class, an anonymous type, a list or a dictionary. The output is compact and keeps the property names exactly as declared. For readable output set WriteIndented. For the camelCase names JavaScript clients expect, pass JsonSerializerOptions.Web (.NET 9+), a ready-made instance with the same defaults ASP.NET Core uses.
Output
The first line is {"Name":"Ada Lovelace","Email":"[email protected]","Age":36,"Roles":["admin","math"]}, in declaration order. The indented version puts each property on its own line with two-space indentation, and the Web line is the same data with name, email, age and roles. The anonymous type gives {"Id":7,"Tags":["a","b"]}, the list [1,2,3] and the dictionary {"pi":3.14,"e":2.72}. SerializeToUtf8Bytes prints 83 bytes: it skips the UTF-16 string, which is what you want when the JSON is going straight to a file, a stream or an HTTP body. Create a JsonSerializerOptions once and reuse it; the serializer caches type metadata per options instance (see the FAQ).
2Rename, reorder and hide properties
Attributes from System.Text.Json.Serialization control the output per property: [JsonPropertyName] sets the key, [JsonIgnore] leaves a property out, and [JsonPropertyOrder] moves it (lower numbers first, default 0). A PropertyNamingPolicy renames everything else in one go: CamelCase, or SnakeCaseLower and KebabCaseLower since .NET 8. Only public properties are written by default, so public fields and private members never show up unless you opt in.
Output
Prints {"Id":42,"PriceCents":1299,"name":"USB-C cable","Sku":"CBL-USB-C"}. Id comes first because of its order of -1, DisplayName is written as name, InternalNote is gone, the internal Sku is there because of [JsonInclude] (non-public members need .NET 8+), and the private supplier field is not. The snake_case line is {"id":42,"price_cents":1299,"name":"USB-C cable","sku":"CBL-USB-C"}: the policy leaves explicit [JsonPropertyName] keys alone. The Point with public fields serializes as {} until you set IncludeFields = true, and then it is {"X":3,"Y":4}. An empty {} is the usual sign of fields, or of properties without a public getter.
3Enums as strings
An enum is written as its underlying number, which is compact but breaks the moment someone reorders the members. To write the name instead, add a JsonStringEnumConverter to the options, or put [JsonConverter(typeof(JsonStringEnumConverter<T>))] on the enum itself (the generic form is .NET 8+ and works with Native AOT) so it is a string everywhere. Since .NET 9, [JsonStringEnumMemberName] sets a custom string for one member.
Output
Without a converter the order prints as {"Id":1001,"Status":2,"Priority":"high-priority","Access":3}: Status is a bare 2, while Priority is already a string because of the attribute on its type. With the converter it is "Status":"Shipped", and the [Flags] value becomes "Read, Write". The camelCase converter writes shipped and read, write, and [JsonStringEnumMemberName] wins over the policy. Because it was created with allowIntegerValues: false, reading "Status":2 back fails with JsonException: The JSON value could not be converted to Order. Path: $.Status | LineNumber: 0 | BytePositionInLine: 18.For turning enum values into strings outside JSON, see converting a string to an enum.
4Leave out null properties
By default every property is written, so a missing value shows up as null. Set DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to skip nulls everywhere (.NET 5+; it replaces the obsolete IgnoreNullValues), or WhenWritingDefault to also skip 0, false and other default values. For one property, put the same condition in a [JsonIgnore] attribute, and use Condition = JsonIgnoreCondition.Never to keep a property the global setting would drop.
Output
The default line is {"Name":"Grace","Email":null,"Visits":0,"Score":null,"Notes":null}. Phone is already missing, because its own attribute drops it while it is null. With WhenWritingNull it is {"Name":"Grace","Visits":0,"Notes":null}, and WhenWritingDefault also removes Visits: {"Name":"Grace","Notes":null}. Notes stays in both because of Never. Careful with WhenWritingDefault: a real count of 0 or a real false disappears too, and the reader can't tell it from a missing value. Once Phone has a value it is written as usual, "Phone":"555-0100", with no options at all.
5Fix "A possible object cycle was detected"
When objects point back at each other, such as a parent with a list of children that each hold their parent, which is typical of Entity Framework navigation properties, the serializer would write forever. It stops at a depth of 64 and throws a JsonException. You have three ways out: ReferenceHandler.IgnoreCycles (.NET 6+) writes null where the loop would start, ReferenceHandler.Preserve writes $id/$ref metadata, or you serialize a shape without the back-reference.
Output
The default prints JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. (the full message continues with a path of $.Members.Team.Members.Team…). IgnoreCycles gives {"Name":"Compilers","Members":[{"Name":"Linus","Team":null},{"Name":"Barbara","Team":null}]}. Preserve keeps the whole graph, but it changes the shape: Members becomes {"$id":"2","$values":[...]} and each Team is {"$ref":"1"}, which only System.Text.Json with the same setting reads back. For an API response, the last line is usually right: {"Name":"Compilers","Members":["Linus","Barbara"]}, built from a projection, or put [JsonIgnore] on the back-reference.
6Non-ASCII and HTML characters
System.Text.Json escapes more than it must. Every non-ASCII character, and the HTML-sensitive <, >, &, ' and ", comes out as a \uXXXX sequence so the output is safe to drop into an HTML page. It is still valid JSON and parses back to the same text, but it is hard to read and larger. The Encoder option changes that.
Output
The default writes Café menu, 5 € and it's "fine" & cheap. JavaScriptEncoder.Create(UnicodeRanges.All) keeps Café and € but still escapes the HTML characters. UnsafeRelaxedJsonEscaping writes Café <b>menu</b> and it's \"fine\" & cheap, escaping only what JSON requires. "Unsafe" means unsafe to paste into HTML or a <script> block without encoding; for files, logs and API bodies it is fine. The last line, Café <b>menu</b>, shows that the escaped default parses back to the original text.
7Which option do you need?
| Setting | What you get | Scope | Since |
|---|---|---|---|
| JsonSerializer.Serialize(obj) | Compact JSON, C# names, enums as numbers | Default | .NET Core 3.0 |
| WriteIndented = true | Pretty-printed output | Options | .NET Core 3.0 |
| JsonSerializerOptions.Web | camelCase names, ASP.NET Core defaults | Options | .NET 9 |
| [JsonPropertyName("x")] | A custom key for one property | One property | .NET Core 3.0 |
| JsonStringEnumConverter | Enum names instead of numbers | Options or one enum | .NET Core 3.0 (generic form .NET 8) |
| JsonIgnoreCondition.WhenWritingNull | Null properties left out | Options or one property | .NET 5 |
| ReferenceHandler.IgnoreCycles | Back-references written as null | Options | .NET 6 |
| ReferenceHandler.Preserve | Full graph with $id / $ref metadata | Options | .NET 5 |
| JavaScriptEncoder.UnsafeRelaxedJsonEscaping | é, €, < and & written as is | Options | .NET Core 3.0 |
Frequently asked questions
What replaces JsonConvert.SerializeObject without Newtonsoft.Json?
JsonSerializer.Serialize(obj) from System.Text.Json, built into .NET since .NET Core 3.0. The common Newtonsoft settings map like this: Formatting.Indented is WriteIndented = true, NullValueHandling.Ignore is DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, StringEnumConverter is JsonStringEnumConverter, ReferenceLoopHandling.Ignore is ReferenceHandler.IgnoreCycles (which writes null for the back-reference), and [JsonProperty("x")] is [JsonPropertyName("x")]. Newtonsoft still works as a NuGet package if you depend on its extra features.
Why does the JSON only contain the base class properties?
The serializer uses the declared type, not the runtime type. With Animal pet = new Dog { Name = "Rex", Breed = "Collie" }, JsonSerializer.Serialize(pet) gives {"Name":"Rex"}. Serialize it as object with JsonSerializer.Serialize<object>(pet) to get {"Breed":"Collie","Name":"Rex"}, or (.NET 7+) put [JsonDerivedType(typeof(Circle), "circle")] on the base class, which writes the derived properties plus a discriminator: {"$type":"circle","Radius":2,"Color":"red"}.
Why do I get "This JsonSerializerOptions instance is read-only"?
An options instance is locked the first time it is used, so changing it afterwards throws InvalidOperationException: This JsonSerializerOptions instance is read-only or has already been used in serialization or deserialization. Configure it fully up front, then keep it in a static readonly field and reuse it. That is also the fast path: the serializer caches what it learns about each type per options instance, so new JsonSerializerOptions() on every call throws that work away.
How are DateTime and other date types written to JSON?
As ISO 8601 strings. A DateTime with an unspecified kind gives "2026-10-04T09:30:00", a UTC one "2026-10-04T09:30:00Z", a DateTimeOffset keeps its offset as "2026-10-04T09:30:00+02:00", DateOnly is "2026-10-04" and a TimeSpan of 90 minutes is "01:30:00". For another format, such as Unix seconds, write a small JsonConverter<DateTime>.
Why does a tuple serialize as {}?
Value tuples store their values in public fields (Item1, Item2), and fields are skipped by default, so JsonSerializer.Serialize((1, "a")) gives {}. With IncludeFields = true it is {"Item1":1,"Item2":"a"}, and element names such as (Id: 1, Name: "a") are lost because they only exist at compile time. Use a record or an anonymous type when the names matter.
How do I write an object to a JSON file?
For a small object, File.WriteAllText("user.json", JsonSerializer.Serialize(obj)). For a large one, stream it instead of building a string: await using var fs = File.Create("user.json"); then await JsonSerializer.SerializeAsync(fs, obj);. Both produce the same file, here {"Name":"Ada"}.