DEV Community

Cover image for PowerShell Get-Content vs Get-Content -Raw: The Real Difference (and Why Your -replace Misses Line Breaks)
arnostorg
arnostorg

Posted on Edited on

PowerShell Get-Content vs Get-Content -Raw: The Real Difference (and Why Your -replace Misses Line Breaks)

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'
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Object[]
5
String
59
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
server=web01
s
Enter fullscreen mode Exit fullscreen mode

$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, ''
Enter fullscreen mode Exit fullscreen mode
server=web01
port=8080
# old block
debug=true
verbose=true
=== with -Raw ===
server=web01
port=8080
verbose=true
Enter fullscreen mode Exit fullscreen mode

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, ''
Enter fullscreen mode Exit fullscreen mode
server=web01
port=8080
verbose=true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
5
1
5
5
Enter fullscreen mode Exit fullscreen mode

.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)" }
Enter fullscreen mode Exit fullscreen mode
2
1
4: debug=true
5: verbose=true
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
String
o
only line
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode
original: 59 bytes
Set-Content:            60 bytes
Set-Content -NoNewline: 59 bytes
True
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ', '
Enter fullscreen mode Exit fullscreen mode
4
web01
80, 443
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
batch type: Object[], size: 1000
batches: 200
Enter fullscreen mode Exit fullscreen mode

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

  • .Count is 1 after -Raw. It's one string.
  • $x[0] is a character when $x is a string, which happens with -Raw and 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.
  • -ReadCount changes $_ from a line into an array of lines.
  • -Raw loads 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)