SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム

SQL ServerのDBNullをC#で安全にハンドリングする5つのイディオム

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

今回は業務系のC#でガチで踏みやすいやつ!!の話。

朝、客先で席に着いてOutlookを開いた瞬間、「また昨日のバッチが落ちてます」と肩を叩かれる、ってこと、ないっすか??スタックトレースを見るとSystem.InvalidCastException:型'System.DBNull'のオブジェクトを型'System.String'にキャストできませんと書いてある。原因はだいたい1行で済む話で、Convert.ToInt32(reader["amount"])が、テーブルのNULLをintにキャストしようとして爆発しているだけ。業務系のC#でDBNullに絡む例外は、たぶん全エラーの2割を占めるくらい頻度が高い。

俺も2社目くらいでこれにガッツリハマった経験があって、半日デバッガで追って詰まった夕方を挟んでから「ん?毎回これ同じ罠やん?」となって、それ以来「DBNull周りは書き方を1パターンに揃える」運用に切り替えたんですよね。今回はC#でSQL ServerのDBNullを安全にハンドリングする5つのイディオムを、DataReader / DataAdapter / EF Coreの3シナリオでコピペで動く形でまとめておきます。.NET Framework 4.7.2 / SQL Server 2016の業務系を想定して、DataAdapter + DataTable +生SQLの構成で動く書き方を中心に置いてて、こんな感じで判断軸も整理してます。

目次

結論:DBNullの握り方は「読み出す層」で決まる

先に答え。SQL Serverの値がNULLになる可能性がある時、C#側の握り方は値を取り出す層がどこかで決まる。

  1. 手で値を読む(DataRow / object) →イディオム1: is DBNull比較
  2. 三項演算子で1行で潰したい →イディオム2: ??演算子+ Convert.IsDBNull
  3. DataReaderでストリーム読み取り →イディオム3: reader.IsDBNull(i)先行チェック
  4. DataTableを扱っているなら →イディオム4: DataRow.Field<int?>()でNullable受け(業務系で最強)
  5. EF Core /モダン構成なら →イディオム5:クラスのプロパティをint?で定義して自然マップ

業務系の現場で一番出番が多いのはイディオム4のDataRow.Field<int?>()。型安全で、null許容型として受けられて、コードがシンプル。残り4つは「使えるけど代替案あり」のポジション。

関連する基礎は既存記事のC# DataAdapter.Update()でDBNull例外が出た時の最短対処でも触れているので、更新側でDBNullが絡む時の単発処理だけ知りたい人はそちらを先に読むと早い。今回はそこから踏み込んで、取得側で5パターン並行して使い分ける時の判断軸に焦点を絞る。

イディオム1: is DBNullで直接比較する基本形

一番低レベルのやり方。DataRowからobjectで値を取って、その場でis DBNullで分岐する。

DataRow row = dt.Rows[0];
object val = row["bikou"];  //この時点で値はobject型

string bikou = val is DBNull
    ? null
    : (string)val;

is DBNullは型パターンマッチ(C# 7+)で、valDBNull.Valueインスタンスかを判定する。val == DBNull.Valueでも書けるが、isのほうが意図が明確で、nullチェックと並べた時の一貫性も高い。

//整数列を扱う場合
int? amount = row["amount"] is DBNull
    ? (int?)null
    : Convert.ToInt32(row["amount"]);

値型(int / DateTime)はNullable<T>で受けて、(int?)nullのように明示的にキャストする必要がある。三項演算子の戻り値型をC#が決められるように、片方を(int?)nullにしておく。

欠点: 1〜2列だけならいいが、列が多くなるとis DBNullの三項演算子が並んで読みにくくなる。20列のテーブルを毎行読むコードでこれを書くと、行頭が三項演算子だらけになる。まとまったテーブル処理にはイディオム4のDataRow.Field<T>()のほうが向く

イディオム2: Convert.IsDBNull + ??演算子のシンプルパターン

少し読みやすくしたい時は、Convert.IsDBNull関数を使って??演算子と組み合わせる。

//文字列列のnull安全取得
string bikou = Convert.IsDBNull(row["bikou"])? null : row["bikou"].ToString();

//あるいは??と組み合わせて空文字フォールバック
string bikouOrEmpty = (Convert.IsDBNull(row["bikou"])? null : row["bikou"].ToString())?? string.Empty;

Convert.IsDBNullval == DBNull.Valueを内部でやっているだけなので挙動はイディオム1と同じだが、関数名の自己説明性が高い。チームに新人が入った時に「これは何をしているの?」と聞かれない書き方になる。

Convert.ToString(null)Convert.ToInt32(null)系はnullを渡すと空文字や0を返してくれるので、これだけで凌げそうに見えるが、

//罠:Convert.ToInt32(DBNull.Value)は0を返す
int amount = Convert.ToInt32(row["amount"]);
// row["amount"] がDBNullだとamount = 0になる
//「金額0円」と「未入力」を区別したい業務系では事故の元

「未入力」と「0」を意味的に区別したい場面では、Convert系を裸で使うと事故る。明示的にIsDBNullを分岐に挟む書き方が安全。

欠点:コードはイディオム1より読みやすいが、結局「nullチェックを毎列で書く」構造は変わらない。多列処理ならイディオム4へ移行するメリットのほうが大きい。

イディオム3: DataReader.IsDBNull(i)で読み取り時に分岐

SqlDataReaderでストリーム処理する時の鉄板。reader.IsDBNull(ordinal)で先にチェックしてから取りに行く。

using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("SELECT id, name, amount FROM customers", conn))
{
    conn.Open();
    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            int id = reader.GetInt32(0);
            string name = reader.IsDBNull(1)? null : reader.GetString(1);
            int? amount = reader.IsDBNull(2)? (int?)null : reader.GetInt32(2);

            //業務処理...
        }
    }
}

reader.GetInt32(2)DBNullが来た瞬間に例外を投げる設計なので、IsDBNull(2)を先に呼んで分岐する習慣をつける。順序を逆にするとInvalidCastExceptionで本番障害になる。

DataReaderはメモリ消費が少なく、大量行(数十万件以上)のバッチ処理に向く。逆に、列数が多くて毎列でIsDBNullを書くのは面倒。

//拡張メソッド化すると行が圧縮できる
public static class SqlReaderExtensions
{
    public static T? GetNullable<T>(this SqlDataReader reader, int ordinal)
        where T : struct
        => reader.IsDBNull(ordinal)? (T?)null : (T)reader.GetValue(ordinal);

    public static string GetStringOrNull(this SqlDataReader reader, int ordinal)
        => reader.IsDBNull(ordinal)? null : reader.GetString(ordinal);
}

//呼び出し側
int? amount = reader.GetNullable<int>(2);
string name = reader.GetStringOrNull(1);

業務系でDataReaderを多用するならこの拡張メソッドをCommon系のクラスに置いておくと、画面ごとに同じ書き方ができる。

欠点: ordinal(カラム序数)で指定するので、SELECT句を変えるとordinalが崩れる。列名指定のreader.GetOrdinal("name")を使うか、SELECT * を避けて固定のSELECT句で運用するのが現実的。

イディオム4: DataRow.Field<T>()でNullable<T>受け(業務系最強)

ここが業務系で圧倒的に出番が多い書き方DataRow.Field<T>()拡張メソッド(System.Data.DataSetExtensions名前空間)は、Nullableを直接受けられる。

using System.Data;

DataRow row = dt.Rows[0];

//値型→ Nullable<T>で受ける
int? amount = row.Field<int?>("amount");      // DBNull → nullに自動変換
DateTime? createdAt = row.Field<DateTime?>("created_at");

//参照型はNullable不要
string name = row.Field<string>("name");      // DBNull → nullに自動変換

これだけ。is DBNullConvert.IsDBNullも不要で、ジェネリックの型引数としてint?を渡すだけでDBNullをnullに翻訳してくれる。読み手にも「nullになり得る列」が型シグネチャから一発で伝わる。

業務系のDataAdapter + DataTable構成で、20列のテーブルを順次表示する画面なら、

foreach (DataRow row in dt.Rows)
{
    var customer = new CustomerVm
    {
        Id = row.Field<int>("id"),                      // NOT NULL
        Name = row.Field<string>("name"),               // NULL許容
        Amount = row.Field<int?>("amount"),             // NULL許容
        CreatedAt = row.Field<DateTime>("created_at"),  // NOT NULL
        Bikou = row.Field<string>("bikou"),             // NULL許容
    };
    customers.Add(customer);
}

20列でも見通しがよく、列ごとにNULL許容かどうかがコードの上で自己ドキュメント化される。DataRow.Field<T>を知っているかどうかで、業務系のコード可読性が体感で2倍違うレベル。

欠点: System.Data.DataSetExtensions.dllの参照が必要(.NET Framework 4.0以降は標準だが、新規プロジェクトテンプレートで省かれることがある)。.NET 5+のSDKスタイルプロジェクトではSystem.Data.DataSetExtensionsパッケージを別途引く必要があるケースもあるので、参照漏れに注意。

イディオム5: EF CoreでNullable<T>プロパティに自然マップ

EF Core / EF6を使えるプロジェクトなら、そもそもDBNullを意識する必要がほぼなくなる。

public class Customer
{
    public int Id { get; set; }                  // NOT NULL
    public string Name { get; set; }             // NULL許容
    public int? Amount { get; set; }             // NULL許容
    public DateTime CreatedAt { get; set; }      // NOT NULL
    public string Bikou { get; set; }            // NULL許容
}

//取得側
var customers = await context.Customers
    .Where(c => c.Amount > 1000)
    .ToListAsync();

// c.AmountがNULLの行はWhere句で除外される(NULL比較はfalse扱い)

クラス側のプロパティをint? / stringで書いておくだけで、EF CoreがDBのNULLをC#のnullに翻訳してくれる。DBNull.Valueという型をC#側で見ることがほぼなくなる。

ただし EF Core固有の罠がある。

//罠:null比較がSQLに翻訳される時の挙動
var noBikou = await context.Customers
    .Where(c => c.Bikou == null)
    .ToListAsync();

// EF Core 6+:自動で `WHERE Bikou IS NULL` に変換される(期待通り)
// EF6 (旧): `WHERE Bikou = NULL` になることがあり、SQLでは常にfalseなので結果0件

EF6の古い挙動を踏むと「nullの行が引けない」障害になる。バージョンと挙動の組み合わせはMicrosoft DocsのNull semanticsで確認しておくと事故が減る。

欠点: EF Coreが使える前提が必要。.NET Framework 4.7.2 / DataAdapter + DataTable +生SQLの業務系現場では、ORM採用が止まっていることが多くて、このイディオムが選択肢に入らないケースも多い。「将来ORMに移れたら強い」くらいの位置づけで頭の片隅に置く。

機能比較表:5イディオムの使い分け

イディオム データアクセス層 コード行数 型安全性 業務系の出番
1: is DBNull DataRow / object 中(毎列で分岐) △ objectキャスト要 △単発処理のみ
2: Convert.IsDBNull + ?? DataRow / object △同上 ○自己説明性高
3: Reader.IsDBNull(i) SqlDataReader 中(拡張メソッドで圧縮可) ○ ordinal指定で型確定 ◎大量行ストリーム
4: DataRow.Field<T?>() DataRow / DataTable ◎ジェネリックで型確定 ◎◎ DataAdapter構成で最強
5: EF Core Nullable<T> EF Coreクラス ◎プロパティ型で確定 × Framework 4.7.2では不可

ハマりポイント:5イディオム共通で踏むやつ

Convert.ToString(DBNull.Value)の挙動

Convert.ToStringDBNullを渡すと空文字を返す。これは便利に見えて、業務系では事故の元。

//罠:DBNullが空文字に化ける
string bikou = Convert.ToString(row["bikou"]);
// row["bikou"] がDBNullの場合、bikouは""(空文字)になる
//「未入力」と「空文字」を区別したい画面で表示が崩れる

「nullと空文字を区別したい」業務要件があるなら、Convert.ToStringは使わずにis DBNullで明示的に分岐する。表示要件次第なので、画面ごとにチェックするのが無難。

Convert.ToInt32(DBNull.Value)は0を返す(再掲)

int amount = Convert.ToInt32(row["amount"]);  // DBNull → 0
//金額0円と未入力が同じ値になる

これも要件次第だが、金額・件数・年齢のような業務的に意味のある数字0を未入力と同義に扱うのは危険。int?で受けて、表示側でnullなら"—"を出すような書き方が無難。

値型と参照型のNullable表記の違い

書き方
値型 Nullable<T>またはT?where T : struct int?, DateTime?, decimal?
参照型 そのまま(既定でnull可能) string, byte[](C# 7まで)
C# 8+参照型 string?で「null許容」を型シグネチャで明示 #nullable enableで挙動変化

C# 8+で#nullable enableを入れたプロジェクトでは、stringstring?がコンパイラレベルで区別される。.NET Framework 4.7.2の現場ではC# 7.3までしか使えないので、参照型のnull許容性はコメントor命名規則で表現する流派が多い。

イディオムを混在させない

1記事で5パターン並べたが、実際のプロジェクトでは1〜2個に絞って統一するのが正解。同じテーブルを扱うコードで、ある画面ではDataRow.Field<int?>を使い、別の画面ではis DBNullの三項演算子を使う、みたいな混在は保守時の認知コストを上げる。チームで「DataRowを扱う時はField一択」と決めるだけで、コードの統一感が出る。

振り返って学んだこと:DBNullは「翻訳ステップ」を1個挟むと事故が減る

C#のnullとADO.NETのDBNull.Valueは別物。DBの世界⇄ C#の世界を行き来する時に、毎回1ステップ翻訳を挟むという習慣を持つと、業務系の本番障害が体感で半分以下になる。

俺もフリーランス案件で「DBNullで落ちた時の障害対応」の見積もりを聞かれたら、いつもこう答える: 「障害そのものは10分で直る、でも全テーブル全列の翻訳ステップを点検して根本対応するなら3日見てください」。場当たり対応と根本対応の温度差が、DBNullはとくに大きい。

業務系で5年・10年触ってきた人は、この翻訳ステップの感覚を肌で知っている。同じスキルセットでTypeScriptのstrictモード(string | nullの扱い)に行っても、感覚は地続きで使える。「nullを境界値で潰す癖」は、SQL Server / DataTableの現場で身につけて、Web系のTypeScript現場でも資産として通用する。

まとめ

  • DBNullの握り方は読み出す層で決まる。手書き読み取り(is DBNull / Convert.IsDBNull)/ DataReader.IsDBNull(i)/ DataRow.Field<T?> / EF Core Nullableの4ライン
  • 業務系のDataAdapter + DataTable構成ではDataRow.Field<T?>()が圧倒的に楽。20列のテーブル処理でも見通しが保てる
  • DataReaderでのストリーム処理はIsDBNull(i)先行チェックを拡張メソッド化して圧縮
  • Convert.ToInt32(DBNull.Value)が0を返すのは便利だが、「未入力vs 0」を区別したい場面では事故の元
  • イディオムは1プロジェクト内で1〜2個に絞って統一する。混在は保守の認知コストを上げる

よくある質問

Q1. is DBNullとConvert.IsDBNull、どっちを使うべき?

挙動は同じなので好みでOK。チームで使う側を統一するのが大事。is DBNullのほうがC# 7+のパターンマッチング文化に合っていて短い。Convert.IsDBNullは関数名で意図が伝わるので新人含むチームに優しい。

Q2. DataRow.Fieldで例外が出るパターンは?

存在しない列名を渡した時のArgumentExceptionと、型変換が失敗した時のInvalidCastException(例: stringの列をint?で受けようとした等)。列名のタイポ・スキーマ変更時に踏みやすいので、列名は定数化しておくと安全。

Q3. Dapperを使う場合は?

DapperはDBNullをC#のnullに自動変換してくれるので、ほぼEF Coreと同じ感覚で使える。int?のプロパティを書いておけばマップされる。.NET Framework 4.7.2でも動くので、ORM全部入りは重いがDapper程度ならアリ、という現場で選ばれる。

Q4. EF Coreでクエリ結果のnullを集計関数(Sum / Avg)に渡すとどうなる?

SumNULL行を無視して足す(SQL仕様)。AvgもNULLを無視して平均を取る。C#側でint?IEnumerableに対してLINQのSumを呼ぶと、nullを0として扱う。同じ「合計」でもDB側とLINQで挙動が違うので、要件確認が大事。

Q5.パフォーマンスに差はある?

5イディオムの差はマイクロ秒オーダーで、業務系の数百〜数千行程度では誤差。設計判断で迷ったら可読性と型安全性を優先で問題ない。性能で意味のある差が出るのは、数十万行のバッチでDataReaderを使うケースくらい。

関連記事

以上!

執筆者

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

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

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次