Skip to main content
SEMastery
DevOpsbeginner

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.

13 min readUpdated September 20, 2025

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.

Figure 1: Plain text logs lose the fields. Structured logs keep them, so search and filtering become easy.

Here is the five-step plan we will follow. Read it once, then we will explain each step slowly.

The 5 Serilog Best Practices

Templates
Enrichers
Correlation
Secrets
Async

Steps

1

Templates

Use message templates, not string interpolation

2

Enrichers

Add useful context to every log

3

Correlation

Tie all logs of one request together

4

Secrets

Never log passwords or tokens

5

Async

Buffer slow sinks on a background thread

Five small habits that turn messy logs into clean, searchable, safe data.

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:

FieldValue
MessageOrder 42 was paid 90
OrderId42
Amount90
LevelInformation
Timestamp2026-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.

Figure 2: With templates, a filtered-out log does almost no work. With interpolation, the string is always built first.

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 askIf 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.

Figure 3: One ID flows through the whole request, so every log can be tied back to it.

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 logWhy
PasswordsDirect account takeover risk
API keys and tokensLets attackers act as your app
Connection stringsCan expose your whole database
Credit card / bank numbersLegal and trust problems
Full personal dataPrivacy 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

Value
Secret?
Mask
Log

Steps

1

Value

You are about to log something

2

Secret?

Is it a password, token, or PII?

3

Mask

If yes, drop or redact it

4

Log

Only safe fields go to the sink

Run each value through this gate before it reaches a log.

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.

Figure 4: With an async sink, the request thread is free quickly while a background worker handles slow writes.

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 Debug on 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

Request
Context
Template
Enrich
Async
Stored

Steps

1

Request

Client hits your API

2

Context

Correlation ID pushed

3

Template

Safe named fields captured

4

Enrich

Machine, env, version added

5

Async

Buffered to background thread

6

Stored

Searchable structured event

From request to stored, searchable event — every best practice in one path.

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 CorrelationId with LogContext so 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

Related Posts