更新履歴(初版のみ・2026年07月25日公開)
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21547445)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「PowerShellスクリプトが遅いときに見るところ ── 配列・パイプライン・突合の勘所」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21547445
- DOI(最新版)
- 10.5281/zenodo.21547445
- DOI(この版)
- 10.5281/zenodo.21547446
「最初は数分で終わっていた集計スクリプトが、データが増えて3時間かかるようになった」── PowerShellによる自動化が定着した現場でよく聞く話です。そして多くの場合、遅さの原因はPowerShellそのものの実行速度ではなく、書き方にあります。
PowerShellは書きやすさを優先した言語なので、「素直に書いた結果として計算量が悪化する」書き方がいくつも存在します。代表格が $array += $item です。見た目は自然ですが、内部では毎回配列全体のコピーが走っており、件数が増えると急激に遅くなります。こうした定番の落とし穴を知っているかどうかで、同じ処理が数時間になるか数十秒になるかが決まります。
この記事では、遅いスクリプトを見たときにまず疑うべき箇所を、影響の大きい順に整理します。あわせて、思い込みで書き換えないための正しい測り方も扱います。
1. まず結論
$array += $itemは使わないでください。PowerShellの配列は固定長で、+=は毎回新しい配列を作って全要素をコピーします。件数の2乗に比例して遅くなります。1- 代わりに
List[T]を使うか、ループ全体の出力を変数に受けます。後者はPowerShellらしく、かつ速い書き方です。 - 突合・検索はハッシュテーブル化が最も効きます。二重ループの線形探索(O(n×m))が、キー参照(おおむねO(n+m))になります。2
- パイプラインは便利ですが、1オブジェクトあたりのコストがあります。大量件数の内側ループでは
foreach文のほうが速いことが多く、測る価値があります。3 - ファイル読み込みは目的で使い分けます。
Get-Content -Rawで一括、-ReadCountでまとめ読み。1行ずつのオブジェクト化が効いている場合があります。4 Get-ChildItemは-Filterを使ってください。プロバイダー側で絞り込むため、取得後にWhere-Objectで捨てるより効率的だと公式も明記しています。5- 文字列の連結は
-joinかStringBuilder。$s += "..."は配列と同じ理由で遅くなります。6 Format-*は画面表示の最後だけ。途中に挟むと後続処理が壊れるうえ、無駄な整形コストがかかります。7- 測ってから直します。
Measure-Commandは出力を破棄するため表示コストを含みません。初回実行の値は使いません。8 - 並列化は最後です。アルゴリズムを直してから検討します(「PowerShellの並列処理」)。
2. まず測る ── Measure-Commandの正しい使い方
推測でチューニングすると、たいてい効かない場所を直します。最初にやるのは計測です。8
# 1回目はモジュール読み込みやJITが含まれるので捨てる
$null = Measure-Command { Invoke-KsAggregate }
# 2回目以降を数回測って、ばらつきも見る
1..3 | ForEach-Object {
(Measure-Command { Invoke-KsAggregate }).TotalSeconds
}
Measure-Command の注意点は2つです。
- スクリプトブロックの出力は破棄されます。画面への整形・描画コストは含まれません。実際の体感が遅い場合、犯人が表示側であることもあります
- 同じ条件で比べる。ファイルキャッシュが温まっているかどうかで、I/Oを含む処理の測定値は大きく変わります
処理内のどこが遅いかを刻みたいときは Stopwatch を使います。
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rows = Import-Csv $csvPath
Write-Verbose "読み込み: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()
$index = $master | Group-Object Code -AsHashTable -AsString
Write-Verbose "索引作成: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()
$result = foreach ($r in $rows) { Join-KsRecord $r $index }
Write-Verbose "突合: $($sw.ElapsedMilliseconds) ms"; $sw.Stop()
このように区間ごとの時間を出せば、「実は読み込みが8割だった」といった事実がすぐ分かります。Write-Verbose を使っているのは、通常実行では出さずに調査時だけ見るためです(「PowerShellの出力ストリームとログ設計」)。
3. 最大の犯人 ── 配列の +=
PowerShellの配列は固定長です。要素を追加することはできず、+= は「新しい配列を作って全要素をコピーし、末尾に新要素を足す」処理に展開されます。1 つまり、n件追加するループは全体でnの2乗に比例するコピーを行います。
# 【NG】件数が増えると急激に遅くなる
$result = @()
foreach ($row in $rows) {
$result += ConvertTo-KsRecord $row # 毎回、配列全体がコピーされる
}
# 【OK-1】List[T] を使う(要素追加が定数時間)
$result = [System.Collections.Generic.List[object]]::new()
foreach ($row in $rows) {
$result.Add((ConvertTo-KsRecord $row))
}
# 【OK-2】ループ全体の出力を変数に受ける(PowerShellらしく、速い)
$result = foreach ($row in $rows) {
ConvertTo-KsRecord $row # 出力がそのまま集約される
}
【OK-2】の書き方は、慣れるとPowerShellで最も自然な形です。foreach 文・ForEach-Object・if などの出力は、そのまま変数に集約できます。この性質は「出力ストリーム」の理解とセットで身につきます。
同じ理屈が文字列にも当てはまります。文字列は不変(immutable)なので、$s += "line" は毎回新しい文字列を作ります。
# 【NG】
$text = ''
foreach ($line in $lines) { $text += "$line`r`n" }
# 【OK-1】-join(最も簡潔)
$text = $lines -join "`r`n"
# 【OK-2】StringBuilder(条件分岐が絡む複雑な組み立て向け)
$sb = [System.Text.StringBuilder]::new()
foreach ($line in $lines) { [void]$sb.AppendLine($line) }
$text = $sb.ToString()
4. 二重ループをやめる ── 突合はハッシュテーブルで
次に効くのが突合(マッチング)です。「受注データの各行について、マスターから商品名を引く」という処理を素直に書くと、こうなります。
# 【NG】受注1万件 × マスター1万件 = 1億回の比較
foreach ($order in $orders) {
$item = $master | Where-Object { $_.Code -eq $order.Code } # 毎回全件走査
$order | Add-Member NoteProperty ItemName $item.Name
}
これをハッシュテーブルに変えると、比較回数が劇的に減ります。2
# 【OK】索引を1度だけ作り、あとはキーで引く
$index = $master | Group-Object -Property Code -AsHashTable -AsString
$result = foreach ($order in $orders) {
$hit = $index[$order.Code] # キー参照は件数に依存しない
[pscustomobject]@{
Code = $order.Code
Qty = $order.Qty
ItemName = if ($hit) { $hit[0].Name } else { $null } # 未登録も検知できる
}
}
Group-Object -AsHashTable は同じキーの要素を配列でまとめるため、値は配列になります(上の $hit[0])。キーが一意だと分かっているなら、自分でハッシュテーブルを組み立ててもかまいません。
$index = @{}
foreach ($m in $master) { $index[$m.Code] = $m } # 一意キー前提
「含まれるかどうか」だけを何度も判定する場合は HashSet が有効です。配列に対する -contains や -in は線形探索なので、判定回数が多いと効いてきます。
$known = [System.Collections.Generic.HashSet[string]]::new(
[string[]]$master.Code, [System.StringComparer]::OrdinalIgnoreCase)
$unknown = $orders | Where-Object { -not $known.Contains($_.Code) }
CSV同士の突合の実務パターンは「PowerShellでExcel・CSV業務処理を自動化する」でも扱っています。
5. パイプラインとforeach文
パイプラインはPowerShellの中心的な機能ですが、1オブジェクトごとにコマンドレット間を流す処理コストがあります。数十万件規模の内側ループでは、foreach 文のほうが速いことが多いです。3
# パイプライン: 読みやすく、逐次処理されるので途中結果を溜め込まない
$rows | Where-Object { $_.Status -eq 'OK' } | ForEach-Object { $_.Amount } |
Measure-Object -Sum
# foreach文: 1件あたりのオーバーヘッドが小さく、大量件数では速い
$sum = 0
foreach ($r in $rows) { if ($r.Status -eq 'OK') { $sum += $r.Amount } }
メモリについては誤解されがちなので補足します。foreach 文は丸かっこの中の式を先に評価してからループに入るため、foreach ($line in (Get-Content $path)) のようにコマンドの実行結果を渡すと、その時点で全件がメモリに載ります。3 一方、遅延列挙する IEnumerable を渡せば1件ずつ列挙されます。したがって巨大ファイルでも、次のように書けば foreach 文のままメモリを使わずに処理できます。
# 全件をメモリに載せずにforeach文で処理する(遅延列挙)
foreach ($line in [System.IO.File]::ReadLines($path)) {
if ($line.StartsWith('ERROR')) { $errors++ }
}
ただし文字コードの既定値が違う点に注意してください。引数1つの ReadLines($path) はUTF-8として読みます(BOMがあればそれを優先)。一方でWindows PowerShell 5.1の Get-Content は、BOMがなければ現在のANSIコードページ(日本語環境ではShift_JIS)で読みます。つまりShift_JISのログをそのまま置き換えると、日本語が文字化けします。差し替えるときは、エンコーディングを明示するオーバーロードを使ってください。
# Shift_JIS(CP932)のログを読む場合
# PowerShell 6.2以降はコードページのプロバイダーが登録済みなので
# GetEncoding(932) をそのまま呼べる(Windows PowerShell 5.1でも同様)
$enc = [System.Text.Encoding]::GetEncoding(932)
foreach ($line in [System.IO.File]::ReadLines($path, $enc)) {
if ($line.StartsWith('ERROR')) { $errors++ }
}
なおPowerShell 7では Get-Content の既定がUTF-8(BOMなし)になっているため、UTF-8のファイルを7で扱っているかぎりは引数1つのオーバーロードでも結果が一致します。置き換えの前に、対象ファイルの文字コードと実行環境のバージョンを必ず確認してください。
判断の目安はこうです。
| 状況 | 選択 |
|---|---|
| 件数が少ない・読みやすさ優先 | パイプライン |
コマンドの結果を渡す巨大な入力(Get-Content など) |
パイプライン、または [IO.File]::ReadLines + foreach 文 |
| すでにメモリ上にある配列を数十万件回す | foreach 文 |
| 配列に対する単純な絞り込み | .Where({...}) / .ForEach({...}) メソッド1 |
.Where() と .ForEach() はPowerShell 4.0で追加された配列のメソッドで、パイプラインを経由しないぶん軽量です。1 ただし対象がすでにメモリ上のコレクションであることが前提です。
6. ファイルとディレクトリのI/O
読み込みは、目的に応じて使い分けます。Get-Content は既定で1行ずつオブジェクトを生成するため、巨大ファイルではこのコストが支配的になります。4
Get-Content $path # 1行ずつ(行数分のオブジェクト生成)
Get-Content $path -Raw # 全体を1つの文字列として一括読み込み
Get-Content $path -ReadCount 1000 # 1000行ずつの配列として渡す(オブジェクト生成を削減)
[System.IO.File]::ReadLines($path) # .NET。逐次列挙で最も軽い(既定はUTF-8)
ディレクトリ走査では -Filter を使ってください。公式ドキュメントが「フィルターはプロバイダーがオブジェクト取得時に適用するため、他のパラメーターより効率的」と明記しています。5
# 【NG】全件取得してから捨てる
Get-ChildItem -Path $root -Recurse | Where-Object { $_.Extension -eq '.log' }
# 【OK】プロバイダー側で絞る
Get-ChildItem -Path $root -Recurse -File -Filter '*.log'
# 数十万ファイル規模で、それでも遅い場合は.NETの列挙を検討する
[System.IO.Directory]::EnumerateFiles($root, '*.log', 'AllDirectories')
書き込みでは、ループ内での都度追記が典型的なボトルネックです。Add-Content は呼び出しのたびにファイルを開いて閉じます。
# 【NG】1万行 = 1万回のオープン/クローズ
foreach ($r in $result) { Add-Content -Path $out -Value ($r -join ',') }
# 【OK-1】まとめて1回で書く
$result | ForEach-Object { $_ -join ',' } | Set-Content -Path $out -Encoding utf8
# 【OK-2】逐次書き出しが必要なら StreamWriter を開きっぱなしにする
$writer = [System.IO.StreamWriter]::new($out, $false, [System.Text.UTF8Encoding]::new($false))
try { foreach ($r in $result) { $writer.WriteLine($r -join ',') } }
finally { $writer.Dispose() }
ネットワーク越しのファイル操作では、そもそもの往復回数が支配的になります。UNCパス特有の注意点は「ネットワーク共有・UNCパスの落とし穴」を参照してください。
7. 見落としがちな小さいコスト
Write-Progressの更新頻度。ループのたびに進捗を更新すると、描画コストが処理時間を上回ることがあります。100件ごとなど間引くか、無人実行では$ProgressPreference = 'SilentlyContinue'で止めますFormat-Table/Format-Listを途中に挟む。フォーマット用オブジェクトに変換されるため、後続の処理が壊れるうえ無駄なコストです。表示は最後だけにします7- ループ内の不変な処理。
Get-Dateの書式文字列生成、正規表現のコンパイル、モジュールの再読み込みなどをループの外に出すだけで効くことがあります Select-Object -Propertyの多用。新しいPSCustomObjectを生成するので、大量件数では無視できません。必要な段階まで元のオブジェクトを保つほうが速い場合があります- 例外の多発。
try/catchを大量に通す設計(存在しないファイルを毎回Get-Itemして例外を握る等)は高コストです。事前にTest-Pathで分岐します
8. 実務の定石(判断表)
| 症状 | 疑う箇所 | 対処 |
|---|---|---|
| 件数が増えると急に遅い | $array += / $string += |
List[T]、ループ出力の変数受け、-join1 |
| 2つのデータの突合が終わらない | 二重ループの線形探索 | ハッシュテーブル / Group-Object -AsHashTable2 |
| 巨大ファイルの読み込みが遅い | Get-Content の行オブジェクト生成 |
-Raw / -ReadCount / [File]::ReadLines4 |
| フォルダー走査が遅い | Where-Object による後段の絞り込み |
Get-ChildItem -Filter、必要なら EnumerateFiles5 |
| 出力ファイルの書き込みが遅い | ループ内の Add-Content |
一括書き込み、または StreamWriter6 |
| CPUは暇なのに終わらない | ネットワーク・I/O待ち | 並列化の出番(記事「並列処理」) |
| 画面表示が遅い | フォーマット処理・描画 | Format-* は最後だけ、$ProgressPreference を無効化7 |
| どこが遅いか分からない | 計測不足 | Stopwatch で区間計測、Measure-Command は2回目以降8 |
9. まとめ
- 最初に測ります。
Measure-Commandは出力を破棄し表示コストを含まない点、初回実行の値が使えない点に注意してください。 $array +=は件数の2乗に比例して遅くなります。List[T]かループ出力の変数受けに置き換えるのが最優先です。- 突合はハッシュテーブル化が最も費用対効果の高い改善です。二重ループを見つけたら索引を作れないか考えてください。
- ファイルI/Oは目的に応じた読み書きを選びます。
-Filterによるプロバイダー側の絞り込み、一括書き込み、StreamWriterの使い分けが基本です。 Format-*を途中に挟まない、進捗更新を間引く、ループ内の不変処理を追い出す ── 小さな積み重ねも件数が多いと効きます。- 並列化は最後の手段です。アルゴリズムを直してから、待ち時間が支配的な処理に対して適用してください。
サンプルコードのダウンロード
この記事で扱ったコードは、そのまま動かせる形にまとめて配布しています。3種類のベンチマークと、計測ヘルパーが入っています。
この記事のサンプルは、PowerShell 7.6 で実際に実行して検証しています(Pester 8件)。zipに含まれる Invoke-SampleTests.ps1 を実行すれば、お手元でも同じ検証を再現できます。
# 構文解析 + 静的解析 + Pesterテスト
./Invoke-SampleTests.ps1
設定値(パス、サーバー名、テナントIDなど)は例です。そのまま本番環境で実行せず、自社の環境に合わせて読み替えてください。
関連記事
- PowerShellの並列処理 ── ForEach-Object -Parallelとジョブの使い分け
- PowerShellの出力ストリームとログ設計 ── Write-Hostをやめる
- PowerShellでExcel・CSV業務処理を自動化する ── 集計・突合・帳票出力の実務レシピ
- PowerShell実用コマンド集 ── 日常作業でよく使う小さな機能を増やす
- Windowsでプログラムのバージョン別速度を正しく比較する方法
- PerfViewとdotnet-traceで「遅い」を特定する
関連する相談領域
合同会社小村ソフトでは、時間がかかりすぎる集計・突合処理の高速化、データ量の増加に耐える処理設計への作り替え、性能劣化の原因調査を扱っています。
参考リンク
-
Microsoft Learn, about_Arrays. PowerShellの配列が固定サイズであり、+= 演算子が新しい配列を作成して既存の要素をコピーしたうえで要素を追加する動作になること、要素の追加・削除を繰り返す場合はコレクション型(List など)の使用が適していること、PowerShell 4.0で追加された .Where() および .ForEach() メソッドについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Group-Object. -AsHashTableによりグループ化結果をハッシュテーブルとして返せること、-AsStringによりキーを文字列として扱えること、返されるハッシュテーブルの値が各グループの要素の配列になることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, about_Foreach. foreach文が丸かっこ内の式を評価してから反復を開始するため、コマンドの実行結果を渡すとその結果がメモリに保持される一方、ForEach-Objectコマンドレットはパイプラインから逐次入力を受け取ること、両者の使い分けについて。遅延列挙の例としてはFile.ReadLines メソッドが、ファイル全体を読み込まずに1行ずつ列挙できること(ReadAllLinesとの違い)を説明している。 ↩ ↩2 ↩3
-
Microsoft Learn, Get-Content. 既定では改行区切りで1行ずつオブジェクトとして返すこと、-Rawによりファイル全体を1つの文字列として読み込めること、-ReadCountにより指定行数ずつまとめてパイプラインへ送れることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Get-ChildItem. -Filterがプロバイダーによってオブジェクト取得時に適用されるため、取得後にフィルタリングする他のパラメーターより効率的であること、-Fileや-Recurseとの組み合わせについて。 ↩ ↩2 ↩3
-
Microsoft Learn, StringBuilder クラス. Stringが不変であるため連結のたびに新しいインスタンスが生成されるのに対し、StringBuilderが可変のバッファ上で文字列を組み立てることについて。あわせてStreamWriter クラスのストリームを開いたまま逐次書き込む動作について。 ↩ ↩2
-
Microsoft Learn, Format-Table. Format系コマンドレットが表示用のフォーマットオブジェクトを生成するため、後続のコマンドへパイプする用途には適さず、パイプラインの最後で使うべきであることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Measure-Command. スクリプトブロックやコマンドの実行時間を測定しTimeSpanを返すこと、測定対象の出力自体は結果に含まれないことについて。あわせてStopwatch クラスによる区間計測について。 ↩ ↩2 ↩3
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
PowerShellの並列処理 ── ForEach-Object -Parallelとジョブの使い分け
ForEach-Object -Parallel・Start-ThreadJob・Start-Jobの違いと使い分け、$using:とスレッド安全性、ThrottleLimitの決め方、かえって遅くなるケースまでを実務目線で整理します。
Write-Hostをやめる ── PowerShellの出力ストリームとログ設計
PowerShellの6つの出力ストリームの使い分け、Write-Hostが抱える問題と正しい使いどころ、関数の戻り値が汚れる原因、-Verboseや-InformationVariableによる呼び出し側制御、構造化ログの残し方を整理します。
PowerShellから外部exeを正しく呼ぶ ── 引数のクォート・終了コード・文字化けの落とし穴
PowerShellからrobocopyや社内EXEを呼ぶと引数が壊れる、終了コードが取れない、出力が文字化けする。PowerShell 7.3の引数渡し変更、停止解析トークン--%、Start-Processの使い分けまでを実務目線で整理します。
PowerShellでの資格情報の安全な扱い ── 平文パスワードをスクリプトから追放する
PowerShellスクリプトの平文パスワードを安全な保管へ移行する手順を整理します。SecureStringの実像と限界、Export-ClixmlによるDPAPI保存の仕組み、SecretManagement/SecretStoreの使いどころまで解説します。
PowerShellスクリプトの引数設計とモジュール化 ── 「動くスクリプト」から「人に渡せるスクリプト」へ
PowerShellスクリプトを他人に渡せる品質に引き上げる手順を整理します。paramブロックと[CmdletBinding()]、入力検証、パイプライン入力、-WhatIf対応、.psm1モジュール化、社内共有とGit管理の勘所まで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- $array += $item という書き方は、なぜ遅いのですか?
- PowerShellの配列は固定長で、要素を追加できないためです。+= 演算子は「新しい配列を作り、既存の全要素をコピーし、末尾に追加する」という処理に展開されます。1件追加するたびに全件コピーが走るので、n件追加すると計算量はnの2乗に比例します。100件なら気づきませんが、1万件を超えると体感できるほど遅くなり、10万件では実用に耐えません。対処は、System.Collections.Generic.List[T]を使ってAddするか、ループ全体の出力を変数に受ける書き方に変えることです。
- Measure-Commandで測ったのに、実際の実行時間と合いません。
- Measure-Commandはスクリプトブロックの実行時間だけを測り、その出力は破棄されるためです。実際の実行では、結果を画面に整形して表示するコスト(フォーマット処理)や、コンソールへの描画時間が加わります。また、初回実行にはモジュールの読み込みやJITコンパイルの時間が含まれるため、1回目の測定値は多くの場合あてになりません。同じ処理を数回実行して2回目以降の値を見る、比較対象は同じ条件で測る、という2点を守ってください。
- 2つのCSVの突合が終わりません。どこを直せばよいですか?
- 内側のループで毎回Where-Objectによる線形探索をしている可能性が高いです。件数がそれぞれ1万件なら、比較回数は1億回になります。片方をハッシュテーブル(またはGroup-Object -AsHashTable)に変換してキーで引く形にすると、比較回数は合計件数に比例する程度まで落ちます。件数が増えるほど効果が大きくなる、最も費用対効果の高い改善です。
- コマンドレットより.NETのAPIを直接呼んだほうが速いと聞きました。常にそうすべきですか?
- いいえ、使い分けです。.NETのAPI(例: System.IO.FileのReadLinesやEnumerateFiles)は確かに高速ですが、PowerShellのプロバイダー機能やワイルドカード、相対パスの解決といった利便性を失い、コードも読みにくくなります。まずは測って、実際にボトルネックだと分かった箇所だけを置き換えてください。数十万ファイルの走査や巨大ファイルの読み込みのように、明確に効く場面に限定するのが実務的です。
- 処理を並列化すれば速くなりますか?
- 待ち時間が支配的な処理なら効果がありますが、順番の問題として最後です。まずは無駄な処理を減らすこと(取得件数を絞る、ループの中から不変な処理を追い出す、突合をハッシュ化する)を先にやってください。アルゴリズムがO(n^2)のまま並列化しても、コア数の分しか速くなりません。逆に、1件が軽い処理を並列化すると、オーバーヘッドで遅くなることもあります。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク