ZetCode

C# ILogger

last modified October 5, 2026

C# ILogger tutorial shows how to write structured logs with the ILogger interface from Microsoft.Extensions.Logging.

ILogger and ILogger<T> are the logging abstraction used across .NET: console applications, ASP.NET Core web applications, and worker services all log through the same interface. A logger is obtained from an ILoggerFactory, or injected into a class by the dependency injection container. Log messages are written with one of the Log* methods, and the Trace, Debug, Information, Warning, Error, and Critical levels let the application filter what is written. Messages are structured templates with named placeholders, not formatted strings, so providers can capture the individual values as searchable properties.

C# ILogger example

The console logger lives in the Microsoft.Extensions.Logging.Console package. Add it to the project first.

$ dotnet add package Microsoft.Extensions.Logging.Console

The project file then contains a PackageReference for the package.

app.csproj
<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.12" />
</ItemGroup>

In the first example, we create a factory, add the console provider, and write two messages.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddConsole());
var logger = factory.CreateLogger("Program");

logger.LogInformation("Application started at {Time}", new DateTime(2026, 10, 5, 9, 30, 0));
logger.LogWarning("Disk space is low: {FreeMb} MB left", 128);

LoggerFactory.Create builds a factory and the callback configures the providers. AddConsole registers the console logger provider.

var logger = factory.CreateLogger("Program");

The string passed to CreateLogger is the category name. It is shown in every message and is used to filter output per category.

logger.LogInformation("Application started at {Time}", new DateTime(2026, 10, 5, 9, 30, 0));

The message contains a named placeholder {Time}. The value is passed as a separate argument after the template, not interpolated into the string.

$ dotnet run
info: Program[0]
      Application started at 10/05/2026 09:30:00
warn: Program[0]
      Disk space is low: 128 MB left

Each entry starts with the level in lowercase (info, warn), followed by the category name and the event id in brackets. The message itself is indented on the next line. When the output is redirected to a file or a pipe, the console formatter writes plain text without color codes.

C# ILogger levels

The ILogger interface exposes one method per level. The levels are ordered by severity, and each provider decides the minimum level it writes.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddConsole());
var logger = factory.CreateLogger("Program");

logger.LogTrace("Trace message");
logger.LogDebug("Debug message");
logger.LogInformation("Information message");
logger.LogWarning("Warning message");
logger.LogError("Error message");
logger.LogCritical("Critical message");

The methods are LogTrace, LogDebug, LogInformation, LogWarning, LogError, and LogCritical. They map to the LogLevel enum values.

$ dotnet run
info: Program[0]
      Information message
warn: Program[0]
      Warning message
fail: Program[0]
      Error message
crit: Program[0]
      Critical message

The console provider defaults to a minimum level of LogLevel.Information, so the trace and debug messages are not written. The Error level is printed as fail and the Critical level as crit.

We can change the minimum level in the factory callback. The following program raises it to Warning.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b =>
{
    b.AddConsole();
    b.SetMinimumLevel(LogLevel.Warning);
});
var logger = factory.CreateLogger("Program");

logger.LogTrace("Trace message");
logger.LogDebug("Debug message");
logger.LogInformation("Information message");
logger.LogWarning("Warning message");
logger.LogError("Error message");
logger.LogCritical("Critical message");

SetMinimumLevel sets the minimum level for the providers registered in the builder. The two calls have the same body, only the configuration changed.

$ dotnet run
warn: Program[0]
      Warning message
fail: Program[0]
      Error message
crit: Program[0]
      Critical message

Now the information message is suppressed as well, and only warnings, errors, and critical messages remain.

C# ILogger message templates

A message is a template with named placeholders. Structured logging stores each placeholder value under its own name, so a provider can index and query the individual properties instead of parsing a formatted string.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddProvider(new StateLoggerProvider()));
var logger = factory.CreateLogger("Program");

int userId = 42;
var time = new DateTime(2026, 10, 5, 9, 30, 0);

logger.LogInformation("User {UserId} logged in at {Time}", userId, time);
logger.LogInformation($"User {userId} logged in at {time}");

class StateLoggerProvider : ILoggerProvider
{
    public ILogger CreateLogger(string categoryName) => new StateLogger();

    public void Dispose() { }
}

class StateLogger : ILogger
{
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

    public bool IsEnabled(LogLevel logLevel) => true;

    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
        Exception? exception, Func<TState, Exception?, string> formatter)
    {
        if (state is IReadOnlyList<KeyValuePair<string, object?>> values)
        {
            foreach (var value in values)
            {
                Console.WriteLine($"  {value.Key} = {value.Value}");
            }
        }

        Console.WriteLine($"  -> {formatter(state, exception)}");
    }
}

The custom provider receives the log state, which for a template is a list of key/value pairs. Printing that list shows how the placeholders are captured.

logger.LogInformation("User {UserId} logged in at {Time}", userId, time);

The placeholder names {UserId} and {Time} become the keys UserId and Time of the state. The template itself is stored under the special key {OriginalFormat}.

logger.LogInformation($"User {userId} logged in at {time}");

String interpolation formats the values before the logger sees them, so the placeholder names are lost. The state only contains the whole message under {OriginalFormat}, and the compiler can also raise the CA2254 analyzer warning about a non-constant message template.

$ dotnet run
  UserId = 42
  Time = 10/5/2026 9:30:00 AM
  {OriginalFormat} = User {UserId} logged in at {Time}
  -> User 42 logged in at 10/05/2026 09:30:00
  {OriginalFormat} = User 42 logged in at 10/5/2026 9:30:00 AM
  -> User 42 logged in at 10/5/2026 9:30:00 AM

The first call keeps the two properties and the original template, while the interpolated second call keeps only a single formatted string.

C# ILogger exceptions

Every Log* method has an overload that takes an exception as its first argument. The exception, including its stack trace, is stored with the entry.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddConsole());
var logger = factory.CreateLogger("Program");

string path = "data.txt";

try
{
    ReadConfig(path);
}
catch (InvalidOperationException ex)
{
    logger.LogError(ex, "Could not read {Path}", path);
}

try
{
    ReadConfig(path);
}
catch (InvalidOperationException ex)
{
    logger.LogError(new EventId(1001, "ReadFailed"), ex, "Could not read {Path}", path);
}

static void ReadConfig(string path)
{
    throw new InvalidOperationException($"Config file '{path}' is missing or empty.");
}

The first handler passes the exception directly after the level method.

logger.LogError(ex, "Could not read {Path}", path);

The exception is not converted to a string, so the provider receives the original object with its full stack trace. The message template and its arguments follow the exception.

logger.LogError(new EventId(1001, "ReadFailed"), ex, "Could not read {Path}", path);

An EventId can be passed before the exception. The numeric id and the name group related errors together and make them easy to find in a log store. The event id appears in the header, so the second entry shows Program[1001] instead of Program[0].

$ dotnet run
fail: Program[0]
      Could not read data.txt
      System.InvalidOperationException: Config file 'data.txt' is missing or empty.
      ...stack trace...
fail: Program[1001]
      Could not read data.txt
      System.InvalidOperationException: Config file 'data.txt' is missing or empty.
      ...stack trace...

The console provider prints the message on the line after the header and the exception below it. The stack trace follows the exception message; its frames and line numbers depend on the machine and the directory the project is in, so the trace is abbreviated here.

C# ILogger with dependency injection

In real applications the logger is normally injected rather than created from a factory. The ILogger<T> form uses the type argument as the category, so the class name appears in every message.

$ dotnet add package Microsoft.Extensions.DependencyInjection

The dependency injection container is in its own package, so it is added separately.

Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var services = new ServiceCollection();

services.AddLogging(b => b.AddConsole());
services.AddSingleton<Greeter>();

using var provider = services.BuildServiceProvider();
var greeter = provider.GetRequiredService<Greeter>();

greeter.Greet("Alice");

class Greeter
{
    private readonly ILogger<Greeter> _logger;

    public Greeter(ILogger<Greeter> logger)
    {
        _logger = logger;
    }

    public void Greet(string name)
    {
        _logger.LogInformation("Hello {Name}", name);
    }
}

AddLogging registers the logging services and the console provider, and AddSingleton registers our own service.

private readonly ILogger<Greeter> _logger;

The constructor asks for ILogger<Greeter>. The container creates the logger with the category name Greeter, which is the type name. No category string is written by hand.

$ dotnet run
info: Greeter[0]
      Hello Alice

The category in the header is Greeter, confirming that ILogger<T> uses the generic type name.

C# log scopes

A scope attaches ambient data to all messages written inside a block. The BeginScope method returns a disposable that ends the scope.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddConsole());
var logger = factory.CreateLogger("Program");

using (logger.BeginScope("Order {OrderId}", 7))
{
    logger.LogInformation("Processing");
    logger.LogWarning("Item {Sku} is out of stock", "ABC-1");
}

The scope carries the order id as a structured property, and every entry written inside the block gets it.

using (logger.BeginScope("Order {OrderId}", 7))

The using statement disposes the scope at the end of the block. The expression is a message template too, so OrderId is captured as a named value.

$ dotnet run
info: Program[0]
      Processing
warn: Program[0]
      Item ABC-1 is out of stock

The console formatter does not print scope contents by default, so the output looks the same as without the scope. The data is still there and is used by providers such as Serilog or Application Insights, which add the scope values to every entry.

C# ILogger guidelines

Inject ILogger<T> into classes instead of creating loggers with CreateLogger, so the category follows the type and the logger is managed by the container. Always use structured placeholders rather than interpolation, and never log secrets, passwords, or personal data.

Choose levels deliberately. Information is for business events, Debug and Trace are for diagnostics, Warning for something unexpected that is handled, Error for failures that need attention, and Critical for unrecoverable problems such as a corrupted state or a lost database connection.

Program.cs
using Microsoft.Extensions.Logging;

using var factory = LoggerFactory.Create(b => b.AddConsole());
var logger = factory.CreateLogger("Program");

if (logger.IsEnabled(LogLevel.Debug))
{
    logger.LogDebug("Detailed state: {State}", BuildState());
}

logger.LogInformation("Work finished");

string BuildState() => string.Join(",", Enumerable.Range(0, 3));

IsEnabled checks the level before the arguments are evaluated. This is worth doing when building the arguments is expensive, because a disabled level would otherwise still run that work.

$ dotnet run
info: Program[0]
      Work finished

Since the minimum level is Information, the debug entry is skipped and BuildState is never called. For hot paths the LoggerMessage source generator is the recommended approach: it creates strongly typed, allocation-free logging methods at compile time.

Source

ILogger Interface - Microsoft Learn

ILogger<TCategoryName> Interface - Microsoft Learn

Logging in .NET - Microsoft Learn

Log levels - Microsoft Learn

In this article we have worked with the ILogger interface in C#.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.