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.
<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.
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.
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.
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.
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.
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.
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.
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.
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
In this article we have worked with the ILogger interface in C#.
Author
List all C# tutorials.