5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
A diary that you can actually search
Imagine you keep a daily diary. One way is to write full sentences like: "Today Ravi bought 3 mangoes for 90 rupees." That reads nicely, but if your boss asks "how many mangoes did we sell this month?", you must read every page by hand and count.
Now imagine a second diary. It has neat columns: Customer, Item, Quantity, Amount. Same information, but now you can quickly add up the Quantity column, or find every row where Customer is "Ravi." This second diary is structured. The data has labels, so a machine can answer your questions in a second.
Structured logging is exactly this idea for software. Instead of writing logs as one long sentence, you write them as data with named fields. Later, your logging tool can search and filter them instantly. Serilog is the most popular library for doing this in .NET, and it has been the go-to choice through .NET 8, 9, and now .NET 10 (LTS).
In this guide we will walk through 5 simple best practices that make your Serilog logs clean, searchable, safe, and fast. Each one is small. Together they make a big difference.
Why structured logs win
Before the tips, let us see the core idea in one picture. A plain text log throws away structure. A structured log keeps it.
Here is the five-step plan we will follow. Read it once, then we will explain each step slowly.
The 5 Serilog Best Practices
Steps
Templates
Use message templates, not string interpolation
Enrichers
Add useful context to every log
Correlation
Tie all logs of one request together
Secrets
Never log passwords or tokens
Async
Buffer slow sinks on a background thread
A tiny bit of setup first
To use Serilog in an ASP.NET Core app, you add a few packages and tell the app to use Serilog. The most common setup looks like this. Notice that we read the configuration from appsettings.json, which is the recommended way because you can change logging without rebuilding the app.
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Read all Serilog settings from appsettings.json
builder.Host.UseSerilog((context, services, configuration) =>
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
var app = builder.Build();
// Logs a clean line for each HTTP request
app.UseSerilogRequestLogging();
app.MapGet("/", () => "Hello!");
app.Run();One important rule: your normal code should not call Serilog directly. Instead, inject the standard ILogger<T> from Microsoft.Extensions.Logging. Serilog quietly does the work underneath. This keeps your classes clean and not tied to one library.
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public void Pay(int orderId, decimal amount)
{
// Note the named holes: {OrderId} and {Amount}
_logger.LogInformation("Order {OrderId} was paid {Amount}", orderId, amount);
}
}Now let us go through the five practices.
1. Use message templates, not string interpolation
This is the single most important habit. Look at these two lines:
// ❌ Bad: string interpolation glues everything into one string
_logger.LogInformation($"Order {orderId} was paid {amount}");
// ✅ Good: message template keeps OrderId and Amount as real fields
_logger.LogInformation("Order {OrderId} was paid {Amount}", orderId, amount);They look almost the same, but they behave very differently.
With the bad version (the $ interpolation), C# builds the full string before Serilog ever sees it. The result is just text: "Order 42 was paid 90". The structure is gone. You cannot later search for OrderId = 42.
With the good version, Serilog keeps OrderId and Amount as separate, named values. The stored log looks something like this:
| Field | Value |
|---|---|
| Message | Order 42 was paid 90 |
| OrderId | 42 |
| Amount | 90 |
| Level | Information |
| Timestamp | 2026-06-10T09:15:00Z |
Now you can ask your log tool: "show me all events where OrderId is 42." That is the whole point of structured logging.
There is a speed bonus too. If the log level is turned off (say you disabled Debug logs in production), the template version costs almost nothing, because Serilog skips building the message entirely. The interpolation version always builds the string first, even if the log is then thrown away. That is wasted work.
One small tip: use the @ symbol when you want Serilog to capture an object as structured data instead of calling ToString() on it.
var basket = new { Items = 3, Total = 90m };
// @ means "keep the shape of this object as fields"
_logger.LogInformation("Basket created {@Basket}", basket);2. Enrich every log with useful context
An enricher adds extra fields to every log automatically. Think of it like a rubber stamp on every page of your diary that records the date, the shop name, and which staff member was on duty. You never write it by hand, but it is always there.
Common things to enrich with:
- The machine or container name
- The application version
- The environment (Development, Staging, Production)
- The current thread ID
- A correlation ID for the request (we cover this next)
You add enrichers when you build the logger:
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "ShopApi")
.WriteTo.Console()
.CreateLogger();Now every single log carries MachineName, EnvironmentName, and Application without you typing them each time. When something breaks at 2 AM, you instantly know which server and which environment the problem came from.
But be careful: do not over-enrich. Every field you add is stored on every log. If you stamp 30 fields onto millions of logs, your storage bill grows and queries slow down. Only enrich with things you will actually search by.
Here is a simple way to decide:
| Question to ask | If yes |
|---|---|
| Will I ever filter logs by this field? | Keep it as an enricher |
| Is it the same for the whole app? | Use a global property |
| Does it change per request? | Use LogContext (next section) |
| Is it just noise I never query? | Leave it out |
3. Tie all logs of one request together with a correlation ID
Picture a busy hospital. A patient gets a wristband with a unique number. Every test, every note, every prescription is tagged with that number. Later, a doctor can pull up everything that happened to that one patient, in order.
A web request is the patient. A correlation ID is the wristband. When a request comes in, you give it one ID, and every log written during that request carries the same ID. Later you can pull up the full story of one request, even if it touched many parts of your code.
Serilog makes this easy with LogContext. You push the ID once, usually in a small middleware, and Serilog adds it to every log until the request ends.
using Serilog.Context;
app.Use(async (context, next) =>
{
// Use an incoming ID if present, otherwise make a new one
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
// Every log inside this request now carries CorrelationId
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next();
}
});Remember: this only works if you called .Enrich.FromLogContext() when building the logger, which we did in setup. Here is the flow in pictures.
Now if a user complains about a failed order, you find their request's CorrelationId and pull every log for that exact journey. No more guessing.
4. Never log passwords, tokens, or other secrets
This one is about safety. Logs are often stored for a long time and seen by many people. If a password or credit card number sneaks into a log, that is a real security problem.
The biggest danger is logging a whole object. Look at this:
// ❌ Dangerous: this might dump Password, Token, or CardNumber
_logger.LogInformation("User signed up {@User}", user);If user has a Password field, you just wrote the password to your logs. Bad.
There are two simple fixes. First, log only the safe fields you actually need:
// ✅ Safe: log just the id and the email, nothing secret
_logger.LogInformation("User signed up {UserId} {Email}", user.Id, user.Email);Second, for cases where you must pass an object, use a destructuring policy to hide sensitive properties before they are written. You can mask or drop fields like Password, Token, and CardNumber so they never reach a sink.
Here is a short list of things you should almost never log:
| Never log | Why |
|---|---|
| Passwords | Direct account takeover risk |
| API keys and tokens | Lets attackers act as your app |
| Connection strings | Can expose your whole database |
| Credit card / bank numbers | Legal and trust problems |
| Full personal data | Privacy laws may forbid it |
A good rule of thumb: if you would not put it on a public postcard, do not put it in a log.
Safe Logging Checklist
Steps
Value
You are about to log something
Secret?
Is it a password, token, or PII?
Mask
If yes, drop or redact it
Log
Only safe fields go to the sink
5. Use async sinks so logging never slows you down
A sink is where Serilog sends logs: the console, a file, a database, or a service like Seq or Elasticsearch. Some sinks are slow. Writing to a file or over the network takes time. If that happens on the same thread handling your user's request, the user waits.
The fix is Serilog.Sinks.Async. It wraps a slow sink in a background buffer. Your request drops the log into the buffer and moves on instantly. A background thread does the slow writing.
Log.Logger = new LoggerConfiguration()
.WriteTo.Async(a => a.File("logs/app-.log", rollingInterval: RollingInterval.Day))
.CreateLogger();Two more pieces fit with this idea:
First, set a sensible minimum level. The minimum level is the lowest severity Serilog keeps. Events below it are dropped early, before they reach enrichers or sinks. This is your main lever to control log volume and cost. In production, Information is a common floor, with Debug and Verbose turned off.
Second, be careful with the console sink in production. The console is perfect during development, but writing to it can block under heavy load. The modern pattern in containers is to write structured JSON to standard output and let your hosting platform collect and ship the logs.
Putting it all together
Here is a small appsettings.json that uses these ideas: a minimum level, enrichers, and an async file sink alongside the console.
// appsettings.json (shown as text)
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": { "Microsoft.AspNetCore": "Warning" }
},
"Enrich": [ "FromLogContext", "WithMachineName" ],
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Async",
"Args": {
"configure": [
{ "Name": "File", "Args": { "path": "logs/app-.log", "rollingInterval": "Day" } }
]
}
}
]
}
}Notice the Override for Microsoft.AspNetCore. The framework is chatty, so we keep its logs at Warning while our own code logs at Information. This keeps the noise down and the cost low.
Common mistakes to avoid
Even with good intentions, beginners hit the same few traps. Keep this list nearby.
- Using
$"..."interpolation. It destroys structure. Always use named holes. - Logging whole objects blindly. You may leak secrets. Pick fields on purpose.
- Enriching with everything. More fields means more storage and slower queries.
- Logging in tight loops. Thousands of identical logs hide the real signal.
- Leaving
Debugon in production. It floods your logs and raises your bill. - Forgetting to flush on shutdown. Call
Log.CloseAndFlush()so buffered logs are written before the app exits.
A quick flush example for a clean shutdown:
try
{
app.Run();
}
finally
{
// Make sure async/buffered logs are written before exit
Log.CloseAndFlush();
}How the pieces connect
Let us tie the whole journey together. A request comes in, gets a correlation ID, your code logs with safe templates, enrichers stamp shared context, and async sinks write it all without blocking.
A Log's Full Journey
Steps
Request
Client hits your API
Context
Correlation ID pushed
Template
Safe named fields captured
Enrich
Machine, env, version added
Async
Buffered to background thread
Stored
Searchable structured event
When you combine all five habits, your logs stop being a wall of text. They become a clean, searchable record that helps you fix problems fast, keeps secrets out, and never slows your users down.
Quick recap
- Structured logging stores logs as data with named fields, so you can search and filter instantly, like a diary with neat columns.
- Practice 1 — Templates: use message templates like
{OrderId}, never$"..."interpolation. They keep fields and run faster when filtered out. - Practice 2 — Enrichers: stamp every log with useful context (machine, environment, version), but only fields you will actually query.
- Practice 3 — Correlation IDs: push a
CorrelationIdwithLogContextso all logs of one request stay tied together, like a hospital wristband. - Practice 4 — Secrets: never log passwords, tokens, connection strings, or personal data. Log safe fields and use destructuring to redact the rest.
- Practice 5 — Async sinks: wrap slow sinks with
Serilog.Sinks.Async, set a sensible minimum level, and flush on shutdown so logging never blocks your users. - Inject the standard
ILogger<T>in your code; let Serilog do the work underneath.
References and further reading
- Serilog — official site
- Serilog Wiki — Writing Log Events
- Serilog Wiki — Configuration Basics
- Serilog on GitHub
- 5 Serilog Best Practices for Better Structured Logging — Milan Jovanović
- Structured Logging in ASP.NET Core with Serilog — Milan Jovanović
- ASP.NET Core logging with Serilog — .NET Blog (Microsoft)
Related Posts
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Logging Best Practices in ASP.NET Core: A Beginner's Guide
Learn logging best practices in ASP.NET Core: log levels, structured logging, source-generated LoggerMessage, scopes, correlation IDs, and keeping secrets out.
Structured Logging in ASP.NET Core with Serilog: A Beginner's Guide
A friendly, step-by-step guide to structured logging in ASP.NET Core with Serilog: setup, sinks, request logging, and viewing logs on .NET 10.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.