Most migration plans we are handed describe screens. Then somebody asks what happens at 2:15 in the morning, and the room goes quiet. The overnight batch is usually the least documented part of a legacy system and the part the business notices fastest when it breaks: invoices, statements, EDI files, payroll exports, interfaces to a bank or a carrier, the report that lands in an inbox at 6 AM.
This tutorial covers the batch as a migration slice of its own. The method is the same one we use on screens — measure, characterize, run both, cut over with a rollback — but the seams and failure modes are different enough to be worth writing down.
Step 1: find every job, not just the ones people remember
Batch work in these systems hides in at least five places. Check all of them before you estimate anything.
# Windows Task Scheduler, including the tasks nobody admits to
Get-ScheduledTask | Where-Object { $_.State -ne 'Disabled' } |
Select-Object TaskName, TaskPath,
@{n='Action';e={($_.Actions | ForEach-Object { $_.Execute + ' ' + $_.Arguments }) -join '; '}},
@{n='Trigger';e={($_.Triggers | ForEach-Object { $_.StartBoundary }) -join '; '}} |
Export-Csv .\scheduled-tasks.csv -NoTypeInformation
# AT-era jobs and service-hosted loops
Get-Service | Where-Object { $_.Status -eq 'Running' } |
Select-Object Name, DisplayName, @{n='Path';e={(Get-CimInstance Win32_Service -Filter "Name='$($_.Name)'").PathName}}
On the database side:
SELECT j.name, j.enabled, s.name AS schedule_name, s.freq_type, s.active_start_time,
(SELECT COUNT(*) FROM msdb.dbo.sysjobsteps st WHERE st.job_id = j.job_id) AS steps
FROM msdb.dbo.sysjobs j
LEFT JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
LEFT JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
ORDER BY s.active_start_time;
-- and the step bodies, which is where the real logic usually lives
SELECT j.name, st.step_id, st.subsystem, st.database_name, st.command
FROM msdb.dbo.sysjobsteps st
JOIN msdb.dbo.sysjobs j ON j.job_id = st.job_id
ORDER BY j.name, st.step_id;
The five hiding places, in the order they surprise people:
- Task Scheduler calling a VB6 or VBScript executable, often with a command-line switch that changes what it does.
- SQL Server Agent, where
subsystemtells you what you are dealing with:TSQL,CmdExec,SSIS, or the legacyDTSsteps on an old instance. - A Windows service or a
while Trueloop in a VB6 app left logged in on a console session. We have found batch logic running inside a minimized form on a machine under someone's desk. - Access autoexec macros on a workstation with a scheduled
msaccess.exe /xcommand line. - A person. Somebody opens a screen every morning and clicks Post. That is a batch job with a human scheduler, and it still has to be migrated.
For each job record: what triggers it, what it reads, what it writes, how long it runs, who notices if it does not run, and what happens if it runs twice. That last column is the one that matters most and the one nobody has an answer for.
Step 2: characterize the outputs, not the code
A batch job's contract is its output. Usually that means files and table deltas, and both are easier to pin down than a rendered page.
Capture a run in production, or on a restored copy of production data with the clock controlled:
-- before the run
SELECT * INTO batch_baseline.invoice_header FROM dbo.invoice_header;
SELECT * INTO batch_baseline.gl_posting FROM dbo.gl_posting;
Then after the run, diff the tables and hash the files:
SELECT 'added' AS kind, h.* FROM dbo.invoice_header h
EXCEPT SELECT 'added', b.* FROM batch_baseline.invoice_header b;
Get-ChildItem \\fileserver\outbound\2026-03-02\* |
Get-FileHash -Algorithm SHA256 |
Select-Object Path, Hash | Export-Csv .\outbound-hashes.csv -NoTypeInformation
For machine-readable output — EDI, fixed-width bank files, carrier uploads, positional payroll extracts — the bytes are the contract. Compare byte for byte, after masking only the fields that legitimately vary (run timestamp, sequence number, batch id). For human-readable output, compare the parsed data rather than the layout, the same way we treat reports.
Two details that cost us time the first few times:
- Line endings and encoding. A VB6
Print #statement writes CRLF and the system's ANSI code page. .NET 8 writes UTF-8 with\nunless you tell it otherwise. A receiving bank will reject a UTF-8 BOM without explaining why. Pin the encoding explicitly:new StreamWriter(path, false, new UTF8Encoding(false)), orEncoding.GetEncoding(1252)when the contract really is ANSI. - Rounding. VB6
Currencyis a scaled 64-bit integer with four decimal places, and VB6'sRounddoes banker's rounding. FoxPro numerics behave differently again. Usedecimalin .NET, neverdouble, and write a test for a total that ends in a half cent before you migrate anything that touches money.
Step 3: make the job idempotent before you move it
Most legacy batch jobs are not safe to re-run. They find work by state — "post everything where posted_flag = 0" — and if the process dies halfway, half the work is posted and nobody knows where it stopped. Teams cope with a runbook and a phone call.
Do not carry that forward. Before or during the rebuild, give each job a run ledger:
CREATE TABLE batch_run (
batch_run_id INT IDENTITY PRIMARY KEY,
job_name SYSNAME NOT NULL,
business_date DATE NOT NULL,
started_at DATETIME2(3) NOT NULL,
finished_at DATETIME2(3) NULL,
status VARCHAR(20) NOT NULL, -- running | succeeded | failed
rows_affected INT NULL,
CONSTRAINT uq_batch_run UNIQUE (job_name, business_date)
);
The unique constraint on (job_name, business_date) is the whole trick: a second attempt for the same business date either resumes the failed run or fails loudly instead of double-posting. Stamp the batch_run_id onto every row the job writes. Then "undo last night's run" becomes a delete by run id rather than an archaeology project, and your rollback plan stops depending on a database restore.
While you are in there, separate the business date from GETDATE(). A job that cannot be told which date to process cannot be re-run, tested, or backfilled.
Step 4: build the replacement as a worker, one job at a time
Use dotnet new worker and host the schedule outside the code. We prefer keeping the existing trigger — Task Scheduler or SQL Agent — and invoking a console entry point, because the operations team already monitors it and the on-call runbook does not change on day one.
dotnet new worker -o Batch.Host
cd Batch.Host
dotnet add package Microsoft.Data.SqlClient
Keep the job body in a plain class with no scheduling in it, so the characterization harness can call it directly:
public interface IBatchJob
{
string Name { get; }
Task<int> RunAsync(DateOnly businessDate, CancellationToken ct);
}
public sealed class PostInvoicesJob(SqlConnectionFactory factory, ILogger<PostInvoicesJob> log)
: IBatchJob
{
public string Name => "post-invoices";
public async Task<int> RunAsync(DateOnly businessDate, CancellationToken ct)
{
await using var conn = await factory.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
var runId = await BatchRun.StartAsync(conn, tx, Name, businessDate, ct);
var rows = await PostAsync(conn, tx, runId, businessDate, ct);
await BatchRun.SucceedAsync(conn, tx, runId, rows, ct);
await tx.CommitAsync(ct);
log.LogInformation("{Job} {Date} posted {Rows} rows as run {RunId}",
Name, businessDate, rows, runId);
return rows;
}
}
Entry point, so a scheduled task can call Batch.Host.exe post-invoices --date 2026-03-02:
var date = args.Contains("--date")
? DateOnly.Parse(args[Array.IndexOf(args, "--date") + 1])
: DateOnly.FromDateTime(DateTime.Today);
var job = host.Services.GetRequiredKeyedService<IBatchJob>(args[0]);
return await job.RunAsync(date, CancellationToken.None) >= 0 ? 0 : 1;
Things worth getting right at this stage:
- Return a non-zero exit code on failure. A surprising number of legacy batch executables exit 0 after an error dialog nobody sees, which is why a job can be "green" for two years and not have run.
- Time out deliberately. Give long jobs a
CancellationTokenSourcewith a ceiling, and make the timeout an alert. Batch jobs do not hang for interesting reasons; they hang on a lock. - Log a row count per step. Rows in, rows written, rows skipped. This is how you diagnose a bad night without re-running it.
- Do not port the retry loop. Legacy batch code is full of
On Error Resume Nextand three-attempt loops around a flaky share. Decide per case whether the operation is genuinely retryable now that it is idempotent.
Step 5: shadow-run before you cut over
Run the new job against a restored copy of last night's data, on the same business date, and diff its output against the recorded baseline from Step 2. Do that for a full cycle — which for batch means a month-end and a quarter-end, not just a Tuesday. The jobs that break are the ones that only run on the last business day, and the ones whose behavior depends on a holiday calendar.
We usually run three phases:
- Replay. New job, restored data, recorded date. Compare files byte for byte and table deltas row for row. Iterate here; it is cheap.
- Parallel. The old job keeps running in production and owns the outputs. The new job runs against a synchronized copy on the same schedule, writing to a quarantine directory. A comparison step diffs the two sets every morning and emails the differences. Two weeks of this, including a month-end, finds the date-arithmetic bugs that replay does not.
- Cutover. Disable the old trigger, enable the new one, keep the old executable and its scheduled task intact but disabled for at least one full cycle. Rollback is re-enabling one task and running
--datefor the day you are redoing, which works because of Step 3.
Do not skip phase 2 on anything that sends a file to a third party. The failure mode of a bad EDI or ACH file is not a support ticket; it is a phone call from your customer's bank.
Where AI helps, and where it does not
AI is useful on this slice in a narrow way. Handed the VB6 or T-SQL source of a job, a model will produce a readable description of the steps and a first-draft translation quickly, and it is good at generating the tedious parts: the fixed-width record layout parser, the comparison script, the dozens of small assertions around a file format. We use it that way daily.
What it does not do is tell you which of the job's behaviors are contracts. It will quietly fix the rounding, normalize the line endings, replace banker's rounding with away-from-zero, and drop the On Error Resume Next that was silently skipping a bad row every night — changes the code deserves and the downstream system may not tolerate. It cannot know that the eleventh column of the export is ignored by everyone except one carrier's parser. That judgment comes from the recorded baseline and from asking the people who receive the output. Generate in bulk, then have a senior engineer decide what is a contract.
What we would not do
We would not rewrite the batch as the first slice of a migration if the screens are the reason you are modernizing; the batch usually has fewer users and more risk per bug, so it earns its place in the sequence on trigger events — a retiring developer who is the only person who understands it, a 32-bit dependency that will not survive the next server, or an output a partner is about to change.
We would not move a job to a cloud scheduler in the same change that rewrites it. One variable at a time: rewrite behind the existing trigger, prove parity, then move the trigger if there is a reason to.
And we would not port a job we cannot characterize. If nobody can tell us what the output is supposed to look like and there is no sample to compare against, the honest answer is that the job needs a specification conversation before it needs an engineer. That conversation is cheaper than discovering the contract in production.