Home › Topics › PowerShell Automation › File Operations

PowerShell File Operations Every Transfer Script Needs

In a finished transfer script, the line that actually sends the file is usually one line. The forty lines around it are local file work. That means deciding which files to send, making sure they are complete, staging them, verifying them, archiving them, and cleaning up afterward. That local half is where transfer automation succeeds or fails — most "transfer" incidents are really file-selection incidents. The job sent yesterday's file again, or picked up a half-written file, or deleted something it should have archived.

This article is a working tour of that local half, done idiomatically in PowerShell. It covers Test-Path guards, Get-ChildItem selection by pattern and age, and the timestamp and size metadata that drives decisions. It covers Copy-Item and Move-Item used safely, hashing with Get-FileHash, and cleanup that cannot destroy the wrong folder. Everything here works no matter how the remote half happens — module, command-line client, or anything else. That is why it comes early in our PowerShell Automation series. By the end you will have a reusable prep function you can drop into any job.

Test-Path First: Never Assume the Disk

Unattended scripts fail in environments nobody is watching. So the first idiom to build into your fingers is the guard clause. Check that a path exists before using it, and fail with a message that names the missing thing. Test-Path returns $true or $false, and its -PathType parameter lets you insist on the kind of thing you expect — Container for a folder, Leaf for a file:

if (-not (Test-Path -Path 'D:\exports' -PathType Container)) {
    throw "Source folder D:\exports is missing - is the drive mapped?"
}

# Create the staging folder if it does not exist; -Force makes this
# idempotent (safe to run whether or not the folder is already there)
New-Item -Path 'D:\staging' -ItemType Directory -Force | Out-Null

Two details worth noticing. The throw stops the script with an error a scheduler and a log can see — far better than letting later commands fail in confusing ways. And New-Item -ItemType Directory -Force is the standard way to say "make sure this folder exists". It creates the folder if absent and quietly succeeds if present. That means a rebuilt server or a cleaned disk does not break the job. The | Out-Null just discards the object New-Item returns so it does not clutter output.

Selecting Files: Get-ChildItem and Its Sharp Edges

File selection is the heart of a transfer job, and Get-ChildItem is the tool. The basic shape lists files in a folder; the skill is narrowing that list to exactly the files this run should touch, and nothing else.

# All CSV files matching the naming convention, files only, no subfolders
$candidates = Get-ChildItem -Path 'D:\exports' -Filter 'report_*.csv' -File

-Filter applies the name pattern down at the filesystem level, which makes it fast. -File excludes directories so a stray subfolder never sneaks into your pipeline. Now the sharp edge: PowerShell also offers an -Include parameter that looks similar but behaves differently. -Include accepts multiple patterns, but it only takes effect when the command recurses (-Recurse) or when the path ends in a wildcard like D:\exports\*. A junior admin who writes Get-ChildItem -Path 'D:\exports' -Include '*.csv' and gets nothing back has hit one of PowerShell's most common traps. Rule of thumb: use -Filter for one pattern (fastest). Use -Include with -Recurse or a trailing \* when you need several patterns at once.

Filtering by age

Most jobs also need a time window — "files from the last day," or "files older than ten minutes" (a crude but useful way to skip files still being written). That is a Where-Object stage comparing LastWriteTime against a computed cutoff:

$cutoff = (Get-Date).AddMinutes(-10)
$ready = Get-ChildItem -Path 'D:\exports' -Filter 'report_*.csv' -File |
    Where-Object { $_.LastWriteTime -lt $cutoff } |
    Sort-Object LastWriteTime

(Get-Date).AddMinutes(-10) means "ten minutes ago"; the comparison keeps files whose last modification is older than that. The Sort-Object at the end gives you a deterministic processing order — oldest first — which matters when a downstream system cares about sequence. Inside the Where-Object script block, $_ means "the current file coming down the pipeline."

Working with datestamped names

Well-designed transfer flows put the date in the file name — report_YYYYMMDD.csv — precisely so scripts can select by name instead of trusting filesystem timestamps. PowerShell builds the expected name for a run with a format string: $today = Get-Date -Format 'yyyyMMdd', then "report_$today.csv". Selecting by convention like this is more robust than age windows, because a delayed export still gets picked up by name the moment it appears. Naming conventions that sort and parse cleanly are a discipline of their own — our file naming and datestamping series covers designing them.

Whatever the selection rule, log its result before acting on it. A line like "matched 4 files, oldest report_YYYYMMDD.csv" costs nothing and answers the two questions every failed run raises. Did the job see the files at all, and did it see the right ones? Zero matches is a decision point, not an automatic success. Some jobs should exit quietly when there is nothing to send. Others should raise an alarm because the daily file should have been there. Decide which kind of job yours is, and make the script say so out loud.

Timestamps and Sizes: Metadata That Drives Decisions

Every file object Get-ChildItem returns carries the metadata a job's logic runs on. Three properties do most of the work, and two of them hide surprises:

  • LastWriteTime — when the content was last modified. This is the timestamp transfer jobs should almost always use, because it travels with the file's content: copying a file preserves it.
  • CreationTime — when this particular copy of the file came into existence. Copy a file and the copy's CreationTime is the moment of the copy. That produces the famously confusing sight of a file "created" today but "modified" last week. Scripts that filter on CreationTime behave differently on original and copied files; avoid it unless that is exactly what you mean.
  • Length — size in bytes. Useful for logging, for sanity checks ("the daily file is normally about two megabytes; today it is 40 bytes — something upstream failed"), and for settle checks, next.

One more subtlety for jobs that run across midnight or daylight-saving changes: the timestamps above are in local time. Each has a UTC twin (LastWriteTimeUtc). Comparing against a cutoff built from (Get-Date).ToUniversalTime() and the UTC properties sidesteps the twice-a-year hour where local-time arithmetic lies to you.

The settle check: is the file still growing?

A file can exist, match your pattern, and still be mid-write — the exporting application simply has not finished. Send it now and you transfer a truncated file that looks fine until someone opens it. The defensive pattern is a settle check: sample the size twice, a few seconds apart, and only treat the file as ready when the size stops changing:

function Test-FileSettled {
    param([string]$Path, [int]$WaitSeconds = 5)
    $size1 = (Get-Item -Path $Path).Length
    Start-Sleep -Seconds $WaitSeconds
    $size2 = (Get-Item -Path $Path).Length
    return ($size1 -eq $size2)
}

Remember: "the file exists" and "the file is complete" are different facts. A settle check or an age window turns the first fact into the second. Best of all is an upstream convention of writing to a temporary name and renaming when done. Sending files the instant they appear is the classic rookie source of corrupt transfers; the wider integrity picture is covered in reliable transfer and integrity.

Copy-Item and Move-Item, Used Deliberately

The two workhorses look interchangeable and are not. Copy-Item duplicates; the original stays. Move-Item relocates — and how it relocates depends on geography. Within the same volume, a move is a rename: one metadata operation, effectively instant, no second copy of the data ever exists. Across volumes (D: to E:, or to a network share), a move is a copy followed by a delete. This takes time proportional to file size and briefly leaves two copies in the world. That difference is why well-built pipelines stage files on the same volume as their source when possible.

Behavior on collision matters for unattended jobs, too. Copy-Item silently overwrites an existing destination file by default (adding -Force extends that to overwriting read-only files). Move-Item is the opposite: it fails if the destination exists, unless you pass -Force. Decide explicitly which behavior each step of your job wants, and write the parameter down even when it is the default. The next reader should not have to remember collision rules.

The most valuable move trick is the temp-name-then-rename pattern for handing files to other processes. If your job drops files into a folder that something else watches, never write the final name directly — the watcher can grab it half-written. Write under a working extension, then rename:

# Stage under a temporary name, then rename into visibility
Copy-Item -Path $file.FullName -Destination 'D:\outbox\report.csv.part'
Rename-Item -Path 'D:\outbox\report.csv.part' -NewName 'report.csv'

The rename happens within one folder, so it is a single metadata operation — the file never exists under its final, watched-for name in a partial state. The same courtesy applied by whoever writes into your source folder is what makes settle checks unnecessary. This handshake matters just as much on the remote side of a transfer, and the SFTP version of it appears in scripting SFTP transfers from PowerShell.

Build paths with Join-Path, not string glue

A quiet habit that prevents a whole family of bugs: construct every path with Join-Path instead of string concatenation. Join-Path 'D:\archive' $sub inserts the separator correctly whether or not either piece already has one. Meanwhile, "D:\archive" + "\" + $sub invites doubled or missing backslashes the moment someone edits a config value. Just as important, Join-Path works on values that come from parameters and config blocks — which is where all paths should come from anyway. Hard-coding D:\staging in six places guarantees that one of them will be missed when the disk layout changes. Every worked example later in this series therefore defines paths once, at the top.

Get-FileHash: Proof the Bytes Survived

A hash is a short fingerprint computed from a file's contents. Change one byte and the fingerprint changes completely (the full story is in hashing explained). For transfer work, hashes answer two questions: did the file change between selection and sending, and did the destination receive exactly what the source sent?

# SHA256 is the default algorithm - say it anyway, for the next reader
$hash = Get-FileHash -Path 'D:\staging\report.csv' -Algorithm SHA256
"$($hash.Hash)  report.csv" | Add-Content -Path 'D:\staging\manifest.sha256'

Recording hashes into a manifest file alongside the transfer gives the receiving side something to verify against. It gives your logs something to compare when a partner claims "the file arrived corrupted." (When files must be encrypted as well as verified, that step slots in here too. For instance, Sysax FTP Automation can apply OpenPGP encryption to files as part of a transfer task, before anything leaves the staging folder.) Comparing is just string equality on the Hash property. Hash before sending, hash the archived copy later, and any mismatch tells you precisely where the bytes diverged. Automated flows that verify hashes end to end catch silent corruption that size checks miss; the operational patterns are in integrity checking in automated flows.

Archive and Cleanup Without Regret

After a successful send, files should move out of the pickup folder. Otherwise tomorrow's run faces the "did we already send this?" question with no good answer. The clean pattern is an archive folder organized by date token, then a retention rule that prunes old archives:

# Archive sent files into a dated subfolder (yyyyMMdd is a format
# string - it renders as today's date token at run time)
$archive = Join-Path 'D:\archive' (Get-Date -Format 'yyyyMMdd')
New-Item -Path $archive -ItemType Directory -Force | Out-Null
Move-Item -Path 'D:\staging\report_*.csv' -Destination $archive

# Retention: remove archive folders older than 30 days - test first!
Get-ChildItem -Path 'D:\archive' -Directory |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
    Remove-Item -Recurse -WhatIf

That trailing -WhatIf is not decoration. It makes Remove-Item print what it would delete without deleting anything — the only sane way to develop a cleanup rule. Run it, read the list, and only when the list is exactly right replace -WhatIf with the real thing.

Gotcha that has burned real teams: a delete path built from a variable that turns out empty. If $archive is null because an earlier line failed, an expression like Join-Path $archive '*' can resolve somewhere you never intended. In that case, Remove-Item -Recurse -Force will obediently destroy it. Guard every destructive step: if ([string]::IsNullOrWhiteSpace($archive)) { throw 'archive path empty' }. Never combine -Recurse -Force with a path you have not just tested. Deletion deserves the same paranoia as transfer.

Putting It Together: A Reusable Prep Function

Here is the local half assembled into one copyable function. Guard the folders, select by pattern and age, skip unsettled files, stage with hashes recorded, and return the staged set for whatever sends them. The function is deliberately boring — boring is what you want at three in the morning:

function Get-FilesToSend {
    param(
        [Parameter(Mandatory)][string]$SourceDir,
        [Parameter(Mandatory)][string]$StageDir,
        [string]$Pattern = '*.csv',
        [int]$MinAgeMinutes = 10
    )

    if (-not (Test-Path $SourceDir -PathType Container)) {
        throw "Source folder not found: $SourceDir"
    }
    New-Item -Path $StageDir -ItemType Directory -Force | Out-Null

    $cutoff = (Get-Date).AddMinutes(-$MinAgeMinutes)
    $files = Get-ChildItem -Path $SourceDir -Filter $Pattern -File |
        Where-Object { $_.LastWriteTime -lt $cutoff } |
        Sort-Object LastWriteTime

    $staged = foreach ($f in $files) {
        $dest = Join-Path $StageDir $f.Name
        Copy-Item -Path $f.FullName -Destination $dest
        $hash = Get-FileHash -Path $dest -Algorithm SHA256
        "$($hash.Hash)  $($f.Name)" |
            Add-Content -Path (Join-Path $StageDir 'manifest.sha256')
        Get-Item -Path $dest
    }
    return $staged
}

Every job in this series builds on this shape. The parameters live at the top, and the guards fail loudly. The function's output is a clean list of file objects for the transfer step. Notice what it does not do — it never deletes from the source. Removal belongs after the send succeeds, which is a sequencing decision covered with the full job skeleton in real PowerShell transfer job patterns.

When the Local Half Is the Whole Point

Step back and notice the pattern you just built: watch a folder, wait for completeness, stage, hash, hand off, archive. That sequence is so universal that transfer automation tools implement it as configuration rather than code. Sysax FTP Automation, for instance, has folder monitoring built in. It watches a directory and starts a transfer task when files arrive. Its task wizard generates the select-transfer-archive sequence without hand-written script. The honest trade is the same one from the start of this series. A script gives you unlimited custom logic between those steps. A tool gives you the standard steps without the maintenance. Teams often use both — a tool for the routine flows, PowerShell where the logic is genuinely custom.

The Habits to Take With You

The local half of a transfer job comes down to a handful of habits. Guard every path with Test-Path and fail loudly. Select files with -Filter and an explicit age or naming rule. Trust LastWriteTime, distrust CreationTime. Never treat a just-appeared file as complete. Stage on the same volume and rename into visibility. Hash what you send, archive after success, and rehearse every delete with -WhatIf. None of these is difficult. All of them are the difference between a script that works in a demo and one that runs for years.

From here, the natural next step is the remote half. The article on scripting SFTP transfers from PowerShell connects this prep work to a real secure transfer. The article on error handling and logging makes the whole thing observable when nobody is watching. The complete assembled job — config block, logging, exit codes — is in real PowerShell transfer job patterns.

Frequently Asked Questions

What is the difference between -Filter and -Include on Get-ChildItem?
-Filter takes a single pattern and applies it at the filesystem level, making it the fastest option. -Include accepts multiple patterns but only works when you also use -Recurse or end the path with a wildcard like \*. Used without those, it silently matches nothing, which is a very common surprise.
Why does a copied file show a creation date newer than its modified date?
Because CreationTime records when that particular copy of the file was created, while LastWriteTime travels with the content. Copying a file resets the creation time to the moment of the copy but preserves the modification time. Transfer scripts should filter on LastWriteTime for exactly this reason.
Does Move-Item overwrite a file that already exists at the destination?
No — it fails with an error unless you add -Force. Copy-Item behaves the opposite way: it overwrites an existing destination by default. Because the two workhorses disagree, it pays to state your collision behavior explicitly in every job rather than relying on memory.
Which hash algorithm should I use with Get-FileHash?
SHA256, which is also the cmdlet's default. It is fast, universally supported, and collision-resistant enough for integrity checking. Older algorithms like MD5 still appear when a partner's tooling requires them for comparison. They are fine for spotting accidental corruption, but do not choose them for anything new.
How do I safely test a script that deletes old files?
Add -WhatIf to the Remove-Item call: PowerShell prints every file it would delete without touching anything. Read that list carefully, fix your filter until the list is exactly right, and only then remove -WhatIf. Also guard the path variable first — deleting from an empty or wrong variable is how folders vanish.

From the Sysax team: we build secure file transfer software for Windows. Sysax Multi Server is an FTP, FTPS, SFTP, and HTTPS server. Sysax FTP Automation handles scheduled, scripted transfers. Free trials are on the download page.