Excel・文書業務

CSV内の混在した日付形式を標準化し、解析できなかった値を一覧化する

複数の既知の日付形式を ISO 形式の YYYY-MM-DD に変換し、元の CSV は変更せずに保持します。許可された形式に一致しない値や、実在しない暦日については、別の確認用ファイルに出力します。

目次を表示

この翻訳はAIで作成しました。コード、単位、数値は原文と併せて確認してください。各言語のネイティブ話者による校閲は、まだ完了していません。 English

対象読者複数の日付形式が混在した CSV ファイルを受け取り、曖昧な値を自動推測せずに再現可能なクリーンアップ処理を行いたい人向けのガイドです。

準備するもの
  • Python 3.12 と、そのバージョンを起動できるターミナルコマンド。
  • UTF-8 CSV ファイルを保存できるテキストエディタ。
  • スクリプトが outputs の下に新しいフォルダを作成できる作業フォルダ。
  • 必要なのは Python 標準ライブラリのみです: csv, datetime, pathlib.

01解析前に受け入れる日付形式を定義する

日付のクリーンアップは、受け入れる形式を明示すると安全になります。この例では6種類の数値形式を認識し、正常に解析できたすべての値を YYYY-MM-DD に変換します。日付の自動推測は使用しません。

受け入れる入力例意味Python 形式
2026-09-01年-月-日%Y-%m-%d
09/02/2026月/日/年%m/%d/%Y
2026/09/03年/月/日%Y/%m/%d
04-09-2026日-月-年%d-%m-%Y
2026.09.05年.月.日%Y.%m.%d
20260906年月日%Y%m%d

スラッシュやハイフンを使った形式の解釈は運用上の方針です。このチュートリアルでは、09/02/2026 は 2026年9月2日、04-09-2026 は 2026年9月4日を意味します。入力元が異なる規則を使用している場合は、実データを処理する前に受け入れる形式を変更してください。

02小さな合成 CSV を作成する

次のデータセットは合成データであり、この記事のために作成したものです。作業フォルダに mixed_dates.csv として保存してください。6種類の異なる受け入れ形式で記録された有効な日付が6件と、失敗するはずの値が2件含まれています。

csv
record_id,event_date,note
R001,2026-09-01,ISO format
R002,09/02/2026,US slash format
R003,2026/09/03,year first with slashes
R004,04-09-2026,day first with hyphens
R005,2026.09.05,dot separated
R006,20260906,compact numeric
R007,2026-02-30,invalid calendar date
R008,Sep 7 2026,unsupported text format

データレコードは8件あります。最初の6件は正常に解析できるはずです。R007 は認識されるパターンを使用していますが、2月30日は実在する日付ではありません。R008 は、受け入れ形式の一覧に意図的に含めていない英語の月名を使用しています。

03想定されるクリーンアップ結果を手作業で確認する

最初の6つの値は、すべて 2026年9月1日から9月6日までの連続した日付を表しています。したがって、6つの標準化された値が生成されるはずです。残りの2件は、確認用ファイルに元のテキストをそのまま保持します。

record_id元の値想定される標準化値状態
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

したがって、想定件数は全8件、解析成功6件、未解析2件です。クリーンアップ後の CSV にも8件すべてが残り、未解析行の standardized_date は空欄、status は UNPARSED になります。

04許可された形式を解析して新しいファイルを書き出す

次のスクリプトを date_format_cleanup.py として保存してください。元の CSV を読み込み、完全なクリーンアップ済みコピーを書き出すとともに、日付を標準化できなかったレコードだけを含む小さな確認用 CSV も出力します。

python
import csv
from datetime import datetime
from pathlib import Path

SOURCE = Path("mixed_dates.csv")
OUTPUT_DIR = Path("outputs") / "date_cleanup_result"
CLEANED = OUTPUT_DIR / "cleaned_dates.csv"
UNPARSED = OUTPUT_DIR / "unparsed_dates.csv"
DATE_COLUMN = "event_date"

FORMATS = (
    "%Y-%m-%d",
    "%m/%d/%Y",
    "%Y/%m/%d",
    "%d-%m-%Y",
    "%Y.%m.%d",
    "%Y%m%d",
)


def parse_date(value: str) -> str | None:
    text = value.strip()
    for format_string in FORMATS:
        try:
            parsed = datetime.strptime(text, format_string)
        except ValueError:
            continue
        return parsed.strftime("%Y-%m-%d")
    return None


def main() -> None:
    if not SOURCE.is_file():
        raise FileNotFoundError(f"Source CSV not found: {SOURCE}")

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Stop rather than reuse an existing result folder.

    with SOURCE.open("r", encoding="utf-8-sig", newline="") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("CSV has no header row.")
        if DATE_COLUMN not in reader.fieldnames:
            raise ValueError(f"Missing required column: {DATE_COLUMN}")
        if "standardized_date" in reader.fieldnames or "date_status" in reader.fieldnames:
            raise ValueError("Output column name already exists in source CSV.")

        cleaned_fields = [*reader.fieldnames, "standardized_date", "date_status"]
        review_fields = ["row_number", *reader.fieldnames]

        with CLEANED.open("x", encoding="utf-8", newline="") as cleaned_stream, \
             UNPARSED.open("x", encoding="utf-8", newline="") as review_stream:
            cleaned_writer = csv.DictWriter(cleaned_stream, fieldnames=cleaned_fields)
            review_writer = csv.DictWriter(review_stream, fieldnames=review_fields)
            cleaned_writer.writeheader()
            review_writer.writeheader()

            total = 0
            parsed_count = 0
            unparsed_count = 0

            for row_number, row in enumerate(reader, start=2):
                total += 1
                original_value = row[DATE_COLUMN]
                standardized = parse_date(original_value)

                output_row = dict(row)
                if standardized is None:
                    output_row["standardized_date"] = ""
                    output_row["date_status"] = "UNPARSED"
                    review_writer.writerow({"row_number": row_number, **row})
                    unparsed_count += 1
                else:
                    output_row["standardized_date"] = standardized
                    output_row["date_status"] = "PARSED"
                    parsed_count += 1

                cleaned_writer.writerow(output_row)

    if parsed_count + unparsed_count != total:
        raise RuntimeError("Record counts do not balance.")

    print(f"Processed {total} records.")
    print(f"Parsed: {parsed_count}; unparsed: {unparsed_count}.")
    print(f"Cleaned CSV: {CLEANED.as_posix()}")
    print(f"Review CSV: {UNPARSED.as_posix()}")


if __name__ == "__main__":
    main()
text
python date_format_cleanup.py

05想定される出力ファイルを比較する

cleaned_dates.csv には、元の3列に standardized_date と date_status を加えた列が含まれるはずです。元の8件すべてのレコードが残ります。失敗した2行も削除されません。

record_idevent_datestandardized_datedate_status
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

unparsed_dates.csv には、データ行が正確に2行含まれるはずです。元の CSV ヘッダーが物理的な1行目なので、R007 は CSV の8行目、R008 は CSV の9行目です。

row_numberrecord_idevent_date
8R0072026-02-30
9R008Sep 7 2026

06件数とコンソール出力を確認する

以下の想定コンソール出力は、合成入力とスクリプトから手作業で導出したものです。実際の実行ログではありません。

text
Processed 8 records.
Parsed: 6; unparsed: 2.
Cleaned CSV: outputs/date_cleanup_result/cleaned_dates.csv
Review CSV: outputs/date_cleanup_result/unparsed_dates.csv
  1. cleaned_dates.csv に、成功した6行だけではなく8件すべてのデータ行が含まれていることを確認します。
  2. R001 から R006 の standardized dates が 2026-09-01 から 2026-09-06 まで連続していることを確認します。
  3. R007 と R008 の standardized_date が空欄で、status が UNPARSED であることを確認します。
  4. unparsed_dates.csv に R007 と R008 だけが含まれていることを確認します。
  5. OUTPUT_DIR を変更せずにスクリプトを再実行します。以前の結果を上書きするのではなく、FileExistsError で停止するはずです。

07よくあるエラーを確認する

症状確認すること
FileNotFoundErrorSOURCE とターミナルの現在の作業ディレクトリを確認します。
必須列が見つからないヘッダーに event_date が正確に含まれていることを確認するか、DATE_COLUMN を意図的に変更します。
未解析値が多すぎる入力元の規則を確認し、そのデータセットについて文書化され、かつ曖昧でない形式だけを追加します。
月と日の順序が想定と逆になるスラッシュまたはハイフン形式の日付が月優先か日優先かを、形式に追加する前に確認します。
FileExistsError結果フォルダがすでに存在します。上書きせず、内容を確認して新しい出力先を選択します。
正しく見える日付でも失敗する文字列に空白が含まれている、FORMATS にない形式である、または暦日そのものが無効である可能性があります。

失敗率が高いからといって、多数の推測的な形式を追加しないでください。そうすると、検出可能だった不良データが、誤って解釈された日付として静かに通過する可能性があります。

08大規模データに適用する前に制限を理解する

この例は暦日のみを扱います。時刻、タイムゾーン、Excel シリアル日付、ローカライズされた月名、2桁の年、追加の説明テキストを含む値は解析しません。これらの場合は明示的なルールが必要です。

datetime.strptime は暦日を検証するため、2026-02-30 のように形式上は妥当に見える値でも拒否されます。これは有用ですが、正常に解析できた日付が入力値として事実上正しいことを証明するわけではありません。たとえば 2026-09-21 の代わりに 2026-09-12 と入力した誤記でも、有効な暦日として解析されます。

複数の受け入れパターンが同じ文字列を異なる方法で解釈できる場合、FORMATS の順序が重要になります。本番のクリーンアップ処理では、入力元の規則を文書化し、元の値を保持し、失敗を報告し、解析だけに依存せず曖昧な形式を確認する必要があります。

実行・検証の記録

2026-09-20 · 手作業で確認した例 · 対象: Python 3.12 · 標準ライブラリ: csv, datetime, pathlib · 未実行

  • 入力レコードが8件あり、正常解析が6件、失敗が2件になることを手作業で数えました。
  • 受け入れる6つの入力値を、2026-09-01 から 2026-09-06 までの連続した標準化日付に手作業で変換しました。
  • 暦上の確認により 2026-02-30 が無効であり、Sep 7 2026 が FORMATS に記載されたどの形式にも一致しないことを確認しました。
  • 元の CSV の行位置から、確認用の行番号 8 と 9 を手作業で導出しました。
  • 元の CSV が読み取り専用であること、結果が別フォルダに書き込まれること、既存の結果フォルダがある場合は処理が停止することをコードで確認しました。
  • 処理件数、解析成功件数、未解析件数、およびコンソールメッセージの想定値を手作業で導出しました。
検証範囲の限界
  • この回答の作成者はコードを実行しておらず、CSV 出力ファイルは作成されていません。
  • ロケール依存の月名、時刻値、タイムゾーン、Excel シリアル日付、2桁の年、大規模ファイルはテストしていません。
  • 受け入れる日付規則は例示用の方針であり、実際の入力元が異なる意味を使用する場合は変更する必要があります。
  • 公式ドキュメントの URL は既知のドキュメント所在地に基づいて記載していますが、ライブでは確認していません。

サイト全体の執筆・検証方針

参考資料

説明と例は独自に作成しました。関連する動作や概念は、以下の公式資料で確認できます。