How to Log with Multiple Severity Levels (INFO, WARN, ERROR) in Batch Script
A professional log shouldn't just be a list of text; it should be a structured record that allows an administrator to quickly distinguish between routine updates and catastrophic failures. By implementing severity levels (standardized prefixes like [INFO], [WARN], and [ERROR]) you make your logs searchable, filterable, and compatible with enterprise monitoring tools. You can even route different severity levels to different destinations: all messages to a general log file, but only errors to a separate critical-alerts file.
This guide will explain how to build a unified logging subroutine that handles multiple severity levels.
Method 1: The Centralized Logging Subroutine
Instead of using echo directly throughout your script, create a :Log subroutine that handles formatting, timestamping, screen output, and file output in one place. This ensures every log entry has a consistent format regardless of where it is called from.
Implementation
@echo off
setlocal EnableDelayedExpansion
set "LogFile=%~dp0system_audit.log"
set "ErrorLog=%~dp0critical_failures.log"
:: =============================================
:: Main Script Logic
:: =============================================
call :Log INFO "Starting update process..."
call :Log INFO "Checking prerequisites..."
:: Simulate a warning condition
if not exist "%TEMP%\temporary.tmp" (
call :Log WARN "File 'temporary.tmp' not found. This is expected on first run."
)
:: Simulate a critical failure
ping -n 1 -w 1000 db-server >nul 2>&1
if errorlevel 1 (
call :Log ERROR "Connection to DB-Server failed!"
call :Log FATAL "Cannot proceed without database. Aborting."
endlocal
exit /b 1
)
call :Log INFO "Update process completed successfully."
endlocal
exit /b 0
:: =============================================
:: Logging Subroutine
:: Usage: call :Log LEVEL "Message text"
:: LEVEL = INFO, WARN, ERROR, or FATAL
:: =============================================
:Log
setlocal
set "Level=%~1"
set "Message=%~2"
:: Default to INFO if no level specified
if "%Level%"=="" set "Level=INFO"
:: Pad the level to 5 characters for aligned columns
if /i "%Level%"=="INFO" set "PaddedLevel=INFO "
if /i "%Level%"=="WARN" set "PaddedLevel=WARN "
if /i "%Level%"=="ERROR" set "PaddedLevel=ERROR"
if /i "%Level%"=="FATAL" set "PaddedLevel=FATAL"
if not defined PaddedLevel set "PaddedLevel=%Level%"
:: Format the log entry
set "Entry=[%date% %time%] [%PaddedLevel%] %Message%"
:: Output to screen
echo %Entry%
:: Output to main log file
echo %Entry% >> "%LogFile%"
:: Route errors and fatals to the critical log as well
if /i "%Level%"=="ERROR" echo %Entry% >> "%ErrorLog%"
if /i "%Level%"=="FATAL" echo %Entry% >> "%ErrorLog%"
:: If FATAL, write to Event Log (optional, requires admin)
if /i "%Level%"=="FATAL" (
eventcreate /T ERROR /ID 900 /L APPLICATION /SO "%~nx0" ^
/D "%Message%" >nul 2>&1
)
endlocal
exit /b 0
Sample log output (system_audit.log):
[Fri 05/10/2024 14:32:05.47] [INFO ] Starting update process...
[Fri 05/10/2024 14:32:05.48] [INFO ] Checking prerequisites...
[Fri 05/10/2024 14:32:05.49] [WARN ] File 'temporary.tmp' not found. This is expected on first run.
[Fri 05/10/2024 14:32:06.51] [ERROR] Connection to DB-Server failed!
[Fri 05/10/2024 14:32:06.52] [FATAL] Cannot proceed without database. Aborting.
Why the subroutine uses its own setlocal:
The :Log subroutine creates temporary variables (Level, Message, PaddedLevel, Entry). Without its own setlocal, these would leak into the caller's environment, potentially overwriting variables with the same name. Each call to :Log creates a clean local scope that is automatically destroyed when the subroutine returns.
Why call :Log LEVEL "Message" instead of call :Log "Message" "LEVEL":
Placing the level first (call :Log ERROR "message") reads more naturally and matches the convention used by most logging frameworks. The level is also the shorter, more predictable argument: putting it first makes the calling code easier to scan visually.
Method 2: Filtering Log Output by Severity
In production environments, you may want to control which messages appear on screen vs. which are written to the file. A "minimum severity" filter lets you suppress INFO messages on screen while still recording everything in the log file.
@echo off
setlocal EnableDelayedExpansion
set "LogFile=%~dp0verbose.log"
:: Set minimum screen verbosity: 0=all, 1=WARN+ERROR, 2=ERROR only
set "MinScreenLevel=1"
call :FilterLog INFO "Connecting to server..."
call :FilterLog INFO "Loading configuration..."
call :FilterLog WARN "Configuration file has deprecated settings."
call :FilterLog ERROR "Failed to authenticate with server."
endlocal
exit /b 0
:FilterLog
setlocal
set "Level=%~1"
set "Message=%~2"
:: Map level names to numeric severity
set "Severity=0"
if /i "%Level%"=="INFO" set "Severity=0"
if /i "%Level%"=="WARN" set "Severity=1"
if /i "%Level%"=="ERROR" set "Severity=2"
if /i "%Level%"=="FATAL" set "Severity=3"
:: Always write to the log file
echo [%date% %time%] [%Level%] %Message% >> "%LogFile%"
:: Only display on screen if severity meets the minimum threshold
if %Severity% geq %MinScreenLevel% (
echo [%Level%] %Message%
)
endlocal
exit /b 0
How filtering works:
With MinScreenLevel=1:
INFOmessages (severity 0) → written to file only, silent on screenWARNmessages (severity 1) → written to file AND shown on screenERRORmessages (severity 2) → written to file AND shown on screen
This is particularly useful for scheduled tasks where you want a complete log file for auditing but only critical messages on screen (or in the Task Scheduler history).
Method 3: Colored Console Output for Interactive Use
When an administrator is watching the script in real time, color-coded severity levels make errors immediately visible. Windows 10 and later support Virtual Terminal (VT100) escape sequences for colored text.
@echo off
setlocal EnableDelayedExpansion
set "LogFile=%~dp0colored_demo.log"
:: Enable Virtual Terminal escape sequences (Windows 10+)
:: The ESC character (ASCII 27) is generated inline via PowerShell
for /f %%e in ('powershell -NoProfile -Command "[char]27"') do set "ESC=%%e"
call :ColorLog INFO "System startup complete."
call :ColorLog WARN "Disk space below 20%% on D: drive."
call :ColorLog ERROR "Service 'Spooler' failed to start."
call :ColorLog FATAL "Unrecoverable error - shutting down."
endlocal
exit /b 0
:ColorLog
setlocal
set "Level=%~1"
set "Message=%~2"
set "Entry=[%date% %time%] [%Level%] %Message%"
:: Set color based on severity
:: VT100: 32=green, 33=yellow, 31=red, 91=bright red
if /i "%Level%"=="INFO" set "Color=%ESC%[32m"
if /i "%Level%"=="WARN" set "Color=%ESC%[33m"
if /i "%Level%"=="ERROR" set "Color=%ESC%[31m"
if /i "%Level%"=="FATAL" set "Color=%ESC%[91m"
set "Reset=%ESC%[0m"
:: Display with color on screen
echo %Color%%Entry%%Reset%
:: Write plain text to log file (no escape sequences)
echo %Entry% >> "%LogFile%"
endlocal
exit /b 0
How VT100 escape sequences work:
ESC[32mswitches text to green (INFO)ESC[33mswitches text to yellow (WARN)ESC[31mswitches text to red (ERROR)ESC[91mswitches text to bright red (FATAL)ESC[0mresets text to default color
The ESC character (ASCII 27) is generated once at startup via PowerShell and stored in a variable. This avoids the error-prone approaches of embedding invisible characters in the script file or using ALT+27 keyboard entry.
Important notes:
- Windows 10 version 1511 or later required: Older Windows versions do not support VT100 sequences and will display raw escape codes as garbage characters.
- Log files must not contain escape codes: The
:ColorLogsubroutine writes plain text (without color codes) to the log file. Escape sequences in a log file appear as garbled characters when viewed in Notepad or processed by other tools.
How to Avoid Common Errors
Wrong Way: Hardcoding Prefixes on Every Line
:: Maintenance nightmare - changing format requires editing every line
echo [INFO] [%date% %time%] Step 1 >> log.txt
echo [INFO] [%date% %time%] Step 2 >> log.txt
echo [ERROR] [%date% %time%] Failed! >> log.txt
If you decide to change the format (add milliseconds, change the bracket style, add the computer name), you must edit every single line.
Correct Way: Use the :Log subroutine. The format is defined once, and every call in the script follows it automatically.
Problem: Special Characters in Log Messages
If a log message contains &, |, >, <, ^, or %, the echo statement inside the subroutine can break because Batch interprets these as command operators.
Solution: For predictable messages (those you write in the script), avoid these characters. For dynamic content (filenames, error messages from external commands), delayed expansion (!var! instead of %var%) provides some protection because the value is not parsed for special characters. For fully robust handling of arbitrary text, delegate the file write to PowerShell.
Problem: Subroutine Variables Leaking into the Caller
If the :Log subroutine sets variables like Level, Message, or Entry without its own setlocal, these values leak into the calling script. If the caller also uses a variable named Message, it gets silently overwritten by every :Log call.
Solution: Every subroutine should use its own setlocal/endlocal pair, as shown in all methods in this guide.
Problem: Inconsistent Column Alignment
Without padding, levels of different lengths produce misaligned columns:
[INFO] Starting...
[WARN] Low memory
[ERROR] Connection failed
Solution: Pad shorter level names to match the longest one:
[INFO ] Starting...
[WARN ] Low memory
[ERROR] Connection failed
Method 1 demonstrates this with explicit padding assignments.
Best Practices and Rules
1. Define Your Severity Levels
Use a consistent set of levels across all your scripts:
| Level | Purpose | Action |
|---|---|---|
INFO | Routine progress updates | Log only |
WARN | Unexpected but recoverable conditions | Log, possibly alert |
ERROR | Failures that affect the current operation | Log, alert, consider aborting |
FATAL | Unrecoverable failures requiring immediate stop | Log, alert, abort script |
2. Route Critical Messages to Multiple Destinations
INFO messages should go to the log file. ERROR and FATAL messages should additionally go to a separate critical-alerts file, the Windows Event Log, or both, ensuring they are not buried in thousands of routine entries.
3. Always Include Timestamps
Every log entry should have a timestamp. For scripts running for more than a few seconds, this is essential for correlating log entries with events and measuring how long each step took.
4. Abort on FATAL
A FATAL log entry should cause the script to exit with a non-zero exit code. Logging a fatal error and then continuing execution produces confusing logs where the script appears to run normally after reporting an unrecoverable failure.
5. Keep the Subroutine Lightweight
The :Log subroutine is called frequently, potentially hundreds of times in a long-running script. Avoid calling PowerShell from inside the subroutine (as in the colored output method) unless you are in an interactive context where the 1–3 second startup overhead per call is acceptable. For production scripts, use plain text logging (Method 1) for performance and reserve colored output (Method 3) for interactive tools.
Conclusions
Implementing severity levels transforms your Batch script from a simple task runner into a structured monitoring tool. By categorizing messages into INFO, WARN, ERROR, and FATAL through a centralized logging subroutine, you provide a clear audit trail that is easy to filter, professional to read, and invaluable for troubleshooting. This structural clarity is a hallmark of enterprise-grade automation and makes your scripts significantly more maintainable.