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#側の握り方は値を取り出す層がどこかで決まる。
- 手で値を読む(DataRow / object) →イディオム1:
is DBNull比較 - 三項演算子で1行で潰したい →イディオム2:
??演算子+ Convert.IsDBNull - DataReaderでストリーム読み取り →イディオム3:
reader.IsDBNull(i)先行チェック - DataTableを扱っているなら →イディオム4:
DataRow.Field<int?>()でNullable受け(業務系で最強) - 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+)で、valがDBNull.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.IsDBNullはval == 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 DBNullもConvert.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.ToStringはDBNullを渡すと空文字を返す。これは便利に見えて、業務系では事故の元。
//罠: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を入れたプロジェクトでは、stringとstring?がコンパイラレベルで区別される。.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)に渡すとどうなる?
SumはNULL行を無視して足す(SQL仕様)。AvgもNULLを無視して平均を取る。C#側でint?のIEnumerableに対してLINQのSumを呼ぶと、nullを0として扱う。同じ「合計」でもDB側とLINQで挙動が違うので、要件確認が大事。
Q5.パフォーマンスに差はある?
5イディオムの差はマイクロ秒オーダーで、業務系の数百〜数千行程度では誤差。設計判断で迷ったら可読性と型安全性を優先で問題ない。性能で意味のある差が出るのは、数十万行のバッチでDataReaderを使うケースくらい。
関連記事
- C# DataAdapter.Update()でDBNull例外が出た時の最短対処 —取得側だけでなく更新側でDBNullを渡す時の境界線対処に効く
- VB.netのRight / Mid / LeftをC#に翻訳する完全早見表 —移植プロジェクトでDBNullと並んで頻出する文字列処理の置き換えに効く
- C#でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) — DBNull入りデータをLinqで整形する時に効く
以上!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント