You write a perfectly good regex, run it against a config file with -replace, and nothing changes. No error, no warning, just the same file back. Nine times out of ten the regex is fine. The problem is that Get-Content didn't hand you a file, it handed you a list of lines.
This post is about the single switch that changes that: -Raw. What it actually returns, when you need it, and the two or three places where it quietly bites you.
Everything below was tested in PowerShell 7.4 (7.4.6), and every output block is pasted from a real run.
The setup
A tiny config file to play with:
Set-Content -Path .\app.conf -Value @(
'server=web01'
'port=8080'
'# old block'
'debug=true'
'verbose=true'
)
The real difference: many strings vs one string
$lines = Get-Content -Path .\app.conf
$whole = Get-Content -Path .\app.conf -Raw
$lines.GetType().Name
$lines.Count
$whole.GetType().Name
$whole.Length
Object[]
5
String
59
By default, Get-Content emits one string per line down the pipeline, and when you capture it in a variable you get an array. The newline characters are gone; they were used as separators.
With -Raw, you get one single string containing the whole file, newlines included. The Get-Content docs describe it exactly that way: -Raw ignores newline characters and returns the entire contents of a file in one string with the newlines preserved.
That one detail explains every "weird" behavior below.
Quick gut check: indexing.
$lines[0]
$whole[0]
server=web01
s
$lines[0] is the first line. $whole[0] is the first character, because indexing a string gives you a [char].
Why your multi-line -replace does nothing
Say you want to delete the # old block comment together with the line under it:
$pattern = '# old block\r?\ndebug=true\r?\n'
($lines -replace $pattern, '') -join "`n"
"=== with -Raw ==="
$whole -replace $pattern, ''
server=web01
port=8080
# old block
debug=true
verbose=true
=== with -Raw ===
server=web01
port=8080
verbose=true
Without -Raw, -replace runs against each line separately. No single line contains a newline, so a pattern that spans a line break can never match. With -Raw, the regex sees the whole file and the newlines in it, so it works.
(I use \r?\n so the same pattern works on Windows CRLF files and LF files.)
If you already have the lines, you can glue them back together first:
(Get-Content .\app.conf) -join "`n" -replace $pattern, ''
server=web01
port=8080
verbose=true
That works, but -Raw is cleaner and keeps the original line endings instead of forcing `n.
Counting lines: .Count lies to you with -Raw
(Get-Content .\app.conf).Count
(Get-Content .\app.conf -Raw).Count
(Get-Content .\app.conf | Measure-Object -Line).Lines
(Get-Content .\app.conf -Raw | Measure-Object -Line).Lines
5
1
5
5
.Count on the raw version is 1, because it's one object. If a script does if ((Get-Content $f -Raw).Count -gt 100), that check will never be true. Measure-Object with -Line counts lines inside the text, so it gives the same answer either way.
The same thing happens with Select-String:
(Get-Content .\app.conf | Select-String 'true').Count
(Get-Content .\app.conf -Raw | Select-String 'true').Count
Get-Content .\app.conf | Select-String 'true' |
ForEach-Object { "$($_.LineNumber): $($_.Line)" }
2
1
4: debug=true
5: verbose=true
Line by line, you get one match per matching line, with useful line numbers. With -Raw, the whole file is one "line", so you get one match. For grep-style searching, skip -Raw, or just use Select-String -Path directly.
Small trap: a one-line file isn't an array
Set-Content .\one.txt -Value 'only line'
(Get-Content .\one.txt).GetType().Name
(Get-Content .\one.txt)[0]
@(Get-Content .\one.txt)[0]
String
o
only line
Even without -Raw, a file with a single line comes back as a plain string, not an array. [0] then gives you a character. Wrap the call in @( ) when your code expects an array.
Round-tripping: Set-Content adds a trailing newline
You read a file with -Raw, change something, write it back, and Git shows a diff on the last line. Here's why:
$before = (Get-Item .\app.conf).Length
$c = Get-Content .\app.conf -Raw
Set-Content .\copy1.conf -Value $c
Set-Content .\copy2.conf -Value $c -NoNewline
"original: $before bytes"
"Set-Content: $((Get-Item .\copy1.conf).Length) bytes"
"Set-Content -NoNewline: $((Get-Item .\copy2.conf).Length) bytes"
$c.EndsWith("`n")
original: 59 bytes
Set-Content: 60 bytes
Set-Content -NoNewline: 59 bytes
True
The raw string already ends with the file's final newline. Set-Content then adds its own newline after the value, so the file grows by one line break every time you round-trip it. -NoNewline writes the string exactly as it is.
The safe pattern for in-place edits:
$c = Get-Content .\app.conf -Raw
$c = $c -replace 'port=8080', 'port=9090'
Set-Content .\app.conf -Value $c -NoNewline
JSON: use -Raw and stop thinking about it
@'
{
"name": "web01",
"ports": [80, 443]
}
'@ | Set-Content .\server.json
(Get-Content .\server.json).Count
$cfg = Get-Content .\server.json -Raw | ConvertFrom-Json
$cfg.name
$cfg.ports -join ', '
4
web01
80, 443
Without -Raw, a pretty-printed JSON file arrives as 4 separate fragments, none of which is valid JSON by itself. With -Raw, ConvertFrom-Json gets the whole document in one piece. That's exactly the pattern Microsoft Learn uses in its own example (Get-Content -Raw JsonFile.json | ConvertFrom-Json), and the docs show the same example for Windows PowerShell 5.1. If your scripts run on both 5.1 and 7, -Raw is the habit to keep.
Big files: -Raw vs default vs -ReadCount
Test file: 200,000 log lines, about 8 MB.
1..200000 | ForEach-Object { "2026-11-02 08:00:00 INFO request $_ ok" } |
Set-Content .\big.log
$null = Get-Content .\big.log -Raw # warm-up
foreach ($i in 1..3) {
$d = (Measure-Command { $null = Get-Content .\big.log }).TotalMilliseconds
$r = (Measure-Command { $null = Get-Content .\big.log -ReadCount 1000 }).TotalMilliseconds
$w = (Measure-Command { $null = Get-Content .\big.log -Raw }).TotalMilliseconds
"run {0}: default {1,5:N0} ms | -ReadCount 1000 {2,4:N0} ms | -Raw {3,4:N0} ms" -f $i,$d,$r,$w
}
run 1: default 1,339 ms | -ReadCount 1000 159 ms | -Raw 54 ms
run 2: default 1,003 ms | -ReadCount 1000 10 ms | -Raw 109 ms
run 3: default 999 ms | -ReadCount 1000 10 ms | -Raw 107 ms
Your numbers will differ, but the shape is consistent: the default (one pipeline object per line) is by far the slowest. -Raw is fast, but it loads the entire file into memory as one string, which is fine for configs and not great for a 4 GB log.
-ReadCount is the middle ground. It sends lines down the pipeline in batches, so you keep line-by-line logic without paying per-line overhead:
$n = 0
Get-Content .\big.log -ReadCount 1000 | ForEach-Object {
$n++
if ($n -eq 1) { "batch type: $($_.GetType().Name), size: $($_.Count)" }
}
"batches: $n"
batch type: Object[], size: 1000
batches: 200
Note that $_ is now an array of 1000 lines, not a single line. Code written for the default mode ($_ -match 'ERROR') still runs but behaves differently on an array (it filters instead of returning $true/$false), so adjust it deliberately. For huge logs where you only need the end, -Tail is a better tool anyway.
Which one to use
| You want to... | Use | Why |
|---|---|---|
| Loop over lines, filter, grep |
Get-Content (default) |
One object per line, line numbers work |
Regex or -replace across line breaks |
Get-Content -Raw |
The regex needs to see the newlines |
| Parse JSON / XML / a whole document | Get-Content -Raw |
Parser needs the full text in one piece |
| Edit a file and write it back unchanged |
-Raw + Set-Content -NoNewline
|
Avoids the extra trailing newline |
| Count lines | default .Count or Measure-Object -Line
|
.Count on -Raw is always 1 |
| Process a big file line by line, faster | Get-Content -ReadCount 1000 |
Batches lines, far less pipeline overhead |
| Read the end of a huge log | Get-Content -Tail 50 |
Doesn't need the whole file |
Common pitfalls, in one place
-
.Countis 1 after-Raw. It's one string. -
$x[0]is a character when$xis a string, which happens with-Rawand with one-line files. Use@( )if you need an array. -
Multi-line regex silently fails without
-Raw, because each line is matched separately. -
Set-Content adds a trailing newline. Write raw content back with
-NoNewline. -
-ReadCountchanges$_from a line into an array of lines. -
-Rawloads everything into memory. Fine for configs, think twice for multi-GB logs.
If you want to try these without touching real files, you can practice PowerShell file commands in a free browser terminal. No install, nothing to break.
Docs used for this post:
What's the script that bit you because Get-Content returned lines instead of a file? Drop it in the comments.
Top comments (0)