動くコード図鑑技術記事現場の渡り方キャリア論すべての記事About
技術記事

WinForms の DataGridView 仮想モードで10万件を固まらせずに表示する

バイブス父さん
現役の業務SE
2026年9月18日18 min read広告 (PR) を含む場合があります
WinForms の DataGridView 仮想モードで10万件を固まらせずに表示する

みなさんこんにちは!ヒロポンです!

WinForms の DataGridView に検索結果を全部出そうとして、DataSource に10万件を流し込んだ瞬間、フォームがピタッと止まる。

しばらく待てば表示はされる。でもスクロールするたびにカクつく。業務系の一覧画面だと、あるあるですよね。

俺も昔、明細検索の画面で件数の上限を切らずに全件バインドして、テスト中に画面を固めた。「件数を絞れ」で逃げるのが定石なんだけど、そもそも「全件持たせてるのが重い」んですよね。

ここで効いてくるのが DataGridView仮想モード(VirtualMode)。10万件あっても、画面に見えてる十数行の分だけデータを渡す。だからフォームが止まらない。

この記事では、なんで全件バインドが重いのか、仮想モードがどういう仕組みで軽いのか、というところから順番に積み上げます。最後はコピペで動く実装まで。CellValueNeeded の中で重い処理をすると逆に遅くなる、みたいな業務SEが踏みやすい落とし穴も途中で拾っていきます。

DataGridView に10万件を渡すと、なぜ固まるのか

まず「なぜ重いのか」から。ここが分かってないと、仮想モードのありがたみもピンとこないので。

DataGridViewDataSource で全件をバインドすると、内部で行の数だけ DataGridViewRow オブジェクトが生成されます。10万件なら10万個の行オブジェクト。

各行はセルのコレクションを持っていて、スタイル情報やら状態やらをぶら下げてる。これがメモリをじわじわ食う。

しかも初期表示やソート、再描画のたびに、この大量の行を舐める処理が走る。1行1行は軽くても、10万回まわれば止まります。

// ❌ 10万件を丸ごとバインド → 行オブジェクトが10万個作られて固まる
var employees = repository.GetAll();   // 10万件
dataGridView1.DataSource = employees;

見た目はたった1行。なのに裏で10万個の行が生まれてる。え、そんなに??ってなるけど、ここが重さの正体です。

ここまでで分かったこと: DataGridView が重いのは、DataSource バインドで全件ぶんの行オブジェクトを持ってしまうから。表示してるのは十数行なのに、10万件ぶんの器を作ってるのがムダなんですよね。

仮想モードが登場する前 — どう凌いでいたか

仮想モードを知る前、現場ではどう凌いでたか。だいたい2つでした。

1つ目は件数を絞る。「検索は最大1000件まで」みたいに上限を切る。実務ではこれが一番多い。ただ、業務側から「全部見たいんだけど」と言われると詰まる。

2つ目はページング。100件ずつ表示して「次へ」ボタンで切り替える。Web だと当たり前だけど、WinForms の一覧にページャを付けると操作感がもっさりして、現場のオペレーターに嫌がられがちでした。

どっちも「全件を一度に DataGridView に持たせない」ための工夫なんですよね。だったら最初から「持たせない仕組み」があれば早い。それが仮想モードです。

ここまでで分かったこと: 昔からの凌ぎ方(件数制限・ページング)は、要は全件を器に入れないための回避策。仮想モードは、その発想を DataGridView 自身が標準で持ってるやつ、と捉えると腑に落ちます。

仮想モード(VirtualMode)の基本概念

用語を整理します。仮想モードで出てくるのは、ざっくりこの3つ。

  • VirtualMode: DataGridView のプロパティ。true にすると「データは自分で持たない。必要な時に聞くから、その都度あんたが返して」というモードになる。
  • RowCount: 総行数。仮想モードでは DataGridView が件数を知らないので、これを設定して「全部で何行あるか」だけ先に教える。
  • CellValueNeeded: イベント。DataGridView が「この行・この列の値をちょうだい」と聞いてくる。あなたは自前のデータから該当の値を返す。

キモは、DataGridView はデータの中身を持たないこと。

件数(RowCount)だけ知っていて、実際の値は「見えたときに聞く」。データの本体は、あなたが用意した List なり DB なりが持ちます。

ここまでで分かったこと: 仮想モードは VirtualMode = true / RowCount(件数)/ CellValueNeeded(値を返すイベント)の3点セット。DataGridView に中身を持たせず、件数だけ渡して値は都度返す、が基本形です。

仮想モードの仕組み — 見えている行の分だけ聞かれる

一番大事なのが、この「いつ聞かれるか」です。

CellValueNeeded は、画面に描画が必要になったセルの分だけ発火します。10万件あっても、見えてるのが15行なら、聞かれるのはその15行 × 列数のぶんだけ。スクロールして新しい行が見えたら、その行のぶんがまた聞かれる。

流れを図にするとこう。

利用者がスクロールすると DataGridView が再描画し、表示範囲内の行だけ CellValueNeeded を発火して自前リストから値を取り、セルに表示する流れ

だから元データが10万件でも100万件でも、DataGridView 側の負荷は「見えてる行数」でほぼ一定。ここが仮想モードが軽い理由です。器(行オブジェクト)を作らず、見えたセルにだけ値を差し込むイメージ。

ここまでで分かったこと: CellValueNeeded見えているセルの分しか呼ばれない。だから総件数に負荷が比例しない。10万件でもフォームが固まらないのは、この「必要な分だけ聞く」仕組みのおかげです。

実装: 10万件を仮想モードで表示する最小コード

仕組みが分かったところで、動くコードにします。全件バインドから仮想モードへの書き換えを並べるとこんな感じ。

全件を DataSource にバインドする書き方から、VirtualMode と RowCount と CellValueNeeded を使う仮想モードへの書き換え差分

フォーム全体だと、こんな感じになります。読み取り専用の一覧を想定。

using System;
using System.Collections.Generic;
using System.Windows.Forms;

public class EmployeeListForm : Form
{
    private readonly List<Employee> _rows;   // 自前で全件を持つ(軽い POCO)
    private readonly DataGridView _grid;

    public EmployeeListForm(List<Employee> rows)
    {
        _rows = rows;

        _grid = new DataGridView
        {
            Dock = DockStyle.Fill,
            VirtualMode = true,              // ★ 仮想モード
            ReadOnly = true,
            AllowUserToAddRows = false,      // 末尾の空行を出さない
            AutoGenerateColumns = false
        };
        _grid.Columns.Add("Id", "ID");
        _grid.Columns.Add("Name", "氏名");
        _grid.Columns.Add("Dept", "部署");

        // ★ 値を聞かれた時だけ返す
        _grid.CellValueNeeded += Grid_CellValueNeeded;

        // ★ RowCount は CellValueNeeded を張ってから、表示より先に設定する
        _grid.RowCount = _rows.Count;

        Controls.Add(_grid);
        Text = $"社員一覧({_rows.Count:N0} 件)";
    }

    private void Grid_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e)
    {
        // e.RowIndex 行目・e.ColumnIndex 列目の値だけを返す
        var row = _rows[e.RowIndex];
        switch (_grid.Columns[e.ColumnIndex].Name)
        {
            case "Id":   e.Value = row.Id;   break;
            case "Name": e.Value = row.Name; break;
            case "Dept": e.Value = row.Dept; break;
        }
    }
}

public class Employee
{
    public int    Id   { get; set; }
    public string Name { get; set; }
    public string Dept { get; set; }
}

_rows は POCO(プレーンなクラス)の List なので、10万件持っても行オブジェクトほど重くない。DataGridView には件数(RowCount)だけ渡して、値は CellValueNeeded で1セルずつ返す。これで10万件でもフォームは止まりません!!

この CellValueNeeded の中身が「必要な行だけ取り出す」を実際にやってる部分です。念のためコンソールで動かして、10万件でも該当行が一発で取れることを確認しました。

using System;
using System.Collections.Generic;
using System.Diagnostics;

public class Demo
{
    public static void Main()
    {
        // 10万件の POCO を用意(ここは軽い)
        var rows = new List<Employee>(100000);
        for (int i = 0; i < 100000; i++)
            rows.Add(new Employee { Id = i, Name = $"社員{i}", Dept = $"部署{i % 20}" });

        var sw = Stopwatch.StartNew();

        // CellValueNeeded 相当: 見えてる行だけ取り出す想定
        var first = GetCellValue(rows[0], "Name");
        var last  = GetCellValue(rows[99999], "Dept");

        sw.Stop();

        Console.WriteLine($"件数: {rows.Count:N0}");
        Console.WriteLine($"先頭行の氏名: {first}");
        Console.WriteLine($"最終行の部署: {last}");
        Console.WriteLine($"該当行の取り出し: {sw.Elapsed.TotalMilliseconds:F3} ms");
    }

    static object GetCellValue(Employee row, string column)
    {
        switch (column)
        {
            case "Id":   return row.Id;
            case "Name": return row.Name;
            case "Dept": return row.Dept;
            default:     return null;
        }
    }
}

public class Employee
{
    public int    Id   { get; set; }
    public string Name { get; set; }
    public string Dept { get; set; }
}

実行結果:

件数: 100,000
先頭行の氏名: 社員0
最終行の部署: 部署19
該当行の取り出し: 0.070 ms

10万件の POCO から該当行の値を取り出すコンソール実行結果。件数10万でも取り出しは0.07ミリ秒

10万件持っていても、該当行を取り出す処理はインデックスアクセス1発なのでほぼ0ミリ秒。CellValueNeeded が見えてる分しか呼ばれないので、実際の描画もこの軽さで回ります。

動作確認メモ: 上のフォームは mono でコンパイル、データ取り出しのロジックはコンソールで実機確認しています。DataGridView のスクロール時の描画挙動そのものは、Windows + Visual Studio の実機で確認してください。

ここまでで分かったこと: 実装は「POCO の List に全件を持つ」「VirtualMode = trueRowCount を設定」「CellValueNeeded で該当行を返す」の3ステップ。値の取り出しはインデックスアクセスなので、件数が増えても1セルの処理は重くならない。

つまずきポイント — 仮想モードで逆に遅くなる書き方

仮想モードは万能じゃなくて、書き方を間違えると逆に遅くなります。業務SEが踏みやすいやつを2つ。

① CellValueNeeded の中で DB アクセスする

一番やりがちなのがこれ。「見えてる分だけ取ればいいなら、その都度 DB から引けば省メモリじゃん」と考えて、こう書く。

// ❌ CellValueNeeded の中で毎回 DB に取りに行く
private void Grid_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e)
{
    using (var conn = new SqlConnection(_connStr))
    {
        conn.Open();
        var cmd = new SqlCommand(
            "SELECT Name FROM Employees ORDER BY Id OFFSET @r ROWS FETCH NEXT 1 ROWS ONLY", conn);
        cmd.Parameters.AddWithValue("@r", e.RowIndex);
        e.Value = cmd.ExecuteScalar();   // スクロールのたびに何十本も飛ぶ
    }
}

さっき見た通り、CellValueNeeded見えてるセルの数だけ発火する。列が3つで15行見えてたら、1回の再描画で45回呼ばれる。

その全部で SqlConnection を開いてクエリを投げたら、スクロールするたびに何十本もクエリが飛ぶ。全件バインドより体感が悪くなることすらあります。これが罠です。

対処は、データを事前にメモリへ読み込んでおく。さっきの最小コードみたいに、List に一度読んでから CellValueNeeded はそこを参照するだけにする。10万件くらいの POCO なら、一度読んでメモリに置く方が圧倒的に速い。

どうしても全件は持てないほど巨大なら、表示位置の前後だけをページ単位でキャッシュする設計にします。

② RowCount を設定し忘れて「何も表示されない」

もう1つは、動かした時に「あれ、行が1つも出ない??」ってなるやつ。原因はだいたい RowCount の設定漏れです。

仮想モードの DataGridView は、自分が何行あるか知りません。RowCount に件数を代入して初めて、その行数ぶんのスクロール領域ができて CellValueNeeded が呼ばれ始める。CellValueNeeded のロジックだけ完璧に書いても、RowCount が 0 のままだと1行も出ません。

順番も大事。CellValueNeeded を張ってから RowCount を設定する。先に RowCount を入れると、まだハンドラが無い状態で値を聞かれて空セルになることがあります。「イベントを張る → 件数を入れる」の順で覚えておくと安全です。

ここまでで分かったこと: 仮想モードの落とし穴は「CellValueNeeded で重い処理(DB アクセス)をして逆に遅くする」と「RowCount 未設定・順序ミスで表示されない」の2つ。どっちも「見えてる分だけ、事前に用意したデータから返す」を守れば避けられます。

俺の現場ではこう使ってる

実務だと、仮想モードは「検索結果の件数が読めない一覧」で効きます。

たとえば物流系の在庫照会。条件次第で数十件のことも数万件のこともある画面ですよね。件数制限で逃げると業務側から文句が来るし、全件バインドだと重い条件の時に固まる。

こういう時、検索結果を POCO の List に受けて仮想モードで出すと、件数がブレても操作感が一定になる。

判断の目安はこう。表示が読み取り中心で、件数が数千〜十数万件に振れるなら仮想モード。逆に、件数が常に数百件で収まるなら、素直に DataSource バインドの方がコードが短くて保守しやすい。仮想モードは CellValueNeeded を自前で書く手間があるので、軽い画面にまで持ち込むと過剰なんですよね。

編集も絡む画面だと CellValuePushed みたいな別のイベントも要る。だからまずは読み取り専用の重い一覧から入れるのがおすすめです。

まとめ

DataGridView の仮想モードの話でした。振り返ると、こういう積み上げです。

  • DataGridView が重いのは、DataSource バインドで全件ぶんの行オブジェクトを持つから。
  • 仮想モードは、DataGridView件数(RowCount)だけ渡して、値は CellValueNeeded で都度返す仕組み。
  • CellValueNeeded見えてるセルの分しか呼ばれないので、総件数に負荷が比例しない。
  • 落とし穴は「CellValueNeeded で DB アクセスして逆に遅くする」「RowCount 未設定で表示されない」の2つ。

突き詰めると、仮想モードは「画面に必要な分だけデータを渡す」という発想そのものです。

この考え方が身につくと、DataGridView に限らず、大量データを扱う画面の設計で「全部持たせない」判断が自然にできるようになる。10万件でもフォームが固まらない、というのはその副産物なんですよね。件数で毎回悩んでたなら、いい感じに肩の荷が下りるはずです。

よくある質問

仮想モードはどのバージョンの .NET Framework から使えますか?

DataGridView.VirtualMode は .NET Framework 2.0 から使えます。業務系でよくある 4.x 系なら問題なく動きます。古い現場の VS でも仮想モード自体は昔からある機能なので、環境を選ばずに導入できます。

DataSource バインドと仮想モード、どちらを使えばいいですか?

件数で決めます。常に数百件で収まるなら DataSource バインドが素直でコードも短い。件数が数千〜十数万件に振れる読み取り中心の一覧なら仮想モード。仮想モードは CellValueNeeded を自前で書く手間があるので、軽い画面に持ち込むと過剰になります。

CellValueNeeded の中で書式を整えてもいいですか?

軽い書式(数値のカンマ区切りや日付フォーマット)なら問題ありません。ただし CellValueNeeded は頻繁に呼ばれるので、中で重いオブジェクト生成や正規表現を毎回走らせるのは避けます。整形済みの値を事前に List に持たせておくか、DataGridViewCellStyle.Format で表示側に寄せる方が軽いです。

並び替え(ソート)はどうすればいいですか?

仮想モードでは DataGridView の自動ソートは効きません。自前の ListSort や LINQ の OrderBy で並べ替えてから、DataGridView.Invalidate() で再描画します。ソートは「元データ側で並べ替えて、表示を作り直す」と考えると分かりやすいです。

次に読むべき記事

大量データの表示みたいな「知ってると業務画面の作りが一段変わる引き出し」を1つずつ増やしていくと、任される画面のレベルが上がってきます。DataGridView まわりで合わせて読むと効く記事を置いておきます。

以上!

「全件バインドで画面固めたことある……」って人、同じ道を通った仲間だ。どんどんシェア待ってるぜ!!

執筆者

バイブス父さん — 業務 SE 7 年(正社員 2 / フリーランス 5)。現職は SEO 直轄部の AI アドバイザー兼 PL、副業で中小 SIer の CTO。SIer 正社員から独立し、複数のフリーランスエージェント経由で現場を渡り歩いた経験ベースで「業務 SE 視点」の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524(日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


この記事のコードと手順は ぜんぶ動作検証済み。 安心して現場で試してくれ。
バイブス父さん

現役の業務SE。C# / SQL Server 保守の現場から、コードも人もキャリアも全部書く。 実体験ベース。

運営者について