ExcelのデータをSQL Serverへ取り込むときは、「読み込めた」ことと「正しいデータを一度だけ保存できた」ことを分けて確認する必要があります。列の型推測で商品コードの先頭ゼロが失われたり、再実行で同じ行が二重登録されたりするためです。
この記事では、手動で少量のデータを移すSQL Server Import and Export Wizardと、入力検証を組み込めるPythonの方法を説明します。どちらも本番テーブルへ直接上書きせず、まず専用のステージング(一時受け入れ)テーブルへ取り込みます。SQLの方言・ドライバー設定はSQL Server用で、他のデータベースへそのまま流用する例ではありません。
検証範囲:2026年10月20日に公式資料を確認し、後半のPython例は合成Excelデータによるローカル検証を行いました。SQL Server、SSMS、ODBC接続を使う実機テストは未実施です。実運用前に、管理者が用意した検証用データベースで型、権限、トランザクション、同時実行を確認してください。
取り込み方法と必要な環境を選ぶ
- ウィザード:Windows上で対話的に作業する場合の選択肢です。SSMSまたは対応するSQL Server/SSISツール、Excelを読むACEプロバイダー、SQL Serverへ接続するドライバーが必要です。インストール済み構成によって画面や利用できる接続先が異なります。
- Python:pandasとopenpyxlで.xlsxを読み、SQLAlchemyとpyodbcを経由してSQL Serverへ送ります。Microsoft ExcelアプリやACEプロバイダーは、このPython例の読み込みには不要です。Microsoft ODBC Driver for SQL ServerはPythonパッケージとは別に必要です。
- 大量データや定期処理:管理されたCSV/TXT、データベース連携、SSIS等も検討します。CSVなら文字コード、区切り、引用符、改行、日付形式を別途決めます。Import Flat File WizardやBULK INSERTは、.xlsxをそのまま読むための方法ではありません。
方法の全体像はMicrosoftのExcel取り込みガイドで確認できます。業務データは作業用コピーを使い、取り込み先、利用アカウント、復旧手順を事前に決めてください。
Excelを取り込む前に確認すること
- 1行目を一意な列名、2行目以降をデータにします。タイトル行、集計行、結合セル、途中の空白行は取り込み範囲から分離します。フィルターや非表示だけで対象外になるとは考えないでください。
- 商品コードや郵便番号などの識別子は数値ではなく文字列として保存します。表示形式を「00000」にしただけの数値から、元の文字列を復元することはできません。
- 金額の精度、小数桁数、日付形式、NULLの許可、文字数、重複キーの扱いを決めます。不正値を自動的に削除・丸め・NULL化せず、業務上の修正方針を確認します。
- 数式セルは値の出所と再計算状態を確認します。openpyxlは数式を計算するソフトではなく、data_only指定でも保存済みの計算結果を読むだけです。後半の例は数式を拒否し、値だけを格納した.xlsxを対象にします。
- 元のExcelは上書きしません。元データ件数、必要な合計値、列仕様を控え、読み込み後と比較します。
数式と読み込みオプションの詳細:openpyxlの読み込み仕様。
ウィザードの準備:64ビットとプロバイダーを確認する
SSMSで接続先データベースを右クリックし、「タスク」→「データのインポート」から起動する方法があります。メニューや実行ファイルが見当たらない場合は、公式の起動方法と導入構成を確認します。SSMSを入れればExcel接続に必要な部品がすべてそろう、とは限りません。
SSMS 21/SQL Server 2025以降のImport and Export Wizardは64ビット環境のみをサポートします。古い「Excelが表示されなければ32ビットのウィザードを使う」という説明を、すべての現行環境に当てはめないでください。古い構成を使う場合も、ウィザードを実行するプロセスとプロバイダーのビット数を合わせる必要があります。Microsoftのバージョン別注意点を確認してください。
Excelを読むACEと、SQL Serverに接続するOLE DB/ODBCドライバーは別の部品です。Access Database Engine 2016 Redistributableは2025年10月14日にサポート終了となっており、単に古いインストーラーを追加する手順は勧めません。Microsoftは代替としてMicrosoft 365 Access Runtimeを案内していますが、既存Officeのビット数やClick-to-Run/Windows Installerとの互換性を管理者が確認してから導入します。この記事の作業としてインストールやOffice構成の変更は行っていません。
確認先:Access Database Engine 2016の終了案内、Microsoft 365 Access Runtimeの要件と制限。ACEを使う処理を、そのまま無人のサービスやサーバー側処理へ移すことにも制限があります。
ウィザードで専用ステージングへ取り込む
以下は「Inventory」シートにItemCode、Quantity、UnitPrice、AsOfDateという4列がある場合です。後半のPython用テーブルとは別に、管理者が検証用DBへ空のdbo.ExcelInventoryWizardStageを準備した前提で進めます。既存テーブルを削除して作り直す例ではありません。
-- 検証用DBの所有者が別途準備する定義例。既存表へ上書きしない。
CREATE TABLE dbo.ExcelInventoryWizardStage (
ItemCode varchar(20) COLLATE Latin1_General_100_BIN2 NOT NULL PRIMARY KEY,
Quantity int NOT NULL CHECK (Quantity BETWEEN 0 AND 1000000),
UnitPrice decimal(10,2) NOT NULL CHECK (UnitPrice >= 0),
AsOfDate date NOT NULL
);- データソースにMicrosoft Excelを選び、作業用.xlsxのパス、対応するExcelバージョン、「先頭行を列名として使用する」設定を確認します。パスはウィザードを実行する端末から読める場所です。
- 宛先には、導入済みで対応しているMicrosoft OLE DB Driver for SQL Serverなどを選び、サーバー、認証方法、検証用DBを確認します。SQL Server Native Clientを新規構成の既定候補にしません。暗号化と証明書検証を維持します。
- コピー対象のシート一覧でInventory$を選びます。画面上のシート選択は、データソース指定とは別の段階になることがあります。必要な範囲だけを対象にします。
- 宛先をdbo.ExcelInventoryWizardStageにし、「列のマッピング」で4列を名前と型で対応させます。既存の行を削除する設定や、本番表を作り直す設定を選ばないでください。
- プレビューで先頭ゼロ、小数、日付、欠損値を確認します。少数行のプレビューだけでは後半の型違いを見逃すため、入力全体のチェックも必要です。
- 処理対象と宛先を再確認して実行し、件数、警告、切り捨て、変換失敗を確認します。実行結果が失敗でも一部の行が保存済みの可能性があります。同じ表への再実行を急がず、保存状況を調べてください。
SQL Server Native ClientはSQL Server 2022およびSSMS 19から同梱されなくなり、新規開発には推奨されません。Microsoft OLE DB Driverの案内に従って、対応する現行ドライバーを選びます。
Excel側の型推測では、数値と文字が混在する列で値がNULLになったり、長文が欠けたりする場合があります。SQL側の列型を広げるだけでは、読み込み時点で失われた値を直せません。原因を確認せずレジストリやセキュリティ設定を変えるのではなく、入力を整えるか、明示的に検証するPython経路を使います。
SSMSでの確認と、本番反映を分ける
SSMSは接続やクエリ実行に使う管理ツールです。Excelファイルを読む別の簡単な方法として、リンクサーバーの追加・削除を行う必要はありません。リンクサーバー方式はサーバー側のファイル権限、プロバイダー、ログイン対応などの設計が必要なので、ここでは扱いません。
-- ウィザード用の専用表。元データの件数・数量合計と照合する。
SELECT COUNT_BIG(*) AS ImportedRows,
SUM(CONVERT(bigint, Quantity)) AS TotalQuantity
FROM dbo.ExcelInventoryWizardStage;件数と合計が一致しても、商品コードや日付の入れ違いまでは検出できません。列を明示した比較や業務ルールのチェックも行います。ステージングから本番へ反映する処理は、更新キー、既存行との競合、承認、履歴、バックアップを決めたうえで別途設計します。この記事の例は本番テーブルへのINSERT/UPDATE/DELETEを実行しません。
Python例の入力仕様と依存関係
ここからは個人情報を含まない架空の在庫データを扱います。汎用のExcel変換器ではなく、次の固定仕様を満たす小規模ファイル用です。曖昧な入力を黙って補正しないよう、意図的に厳しく検証します。
- ファイルは信頼できる出所の.xlsx、5MiB以内。ZIP内部の宣言展開サイズは合計50MiB以内、エントリー数は1,000以内。悪意あるファイルを安全に処理できることを保証する制限ではありません。
- シートはInventoryの1枚だけ。先頭行はItemCode、Quantity、UnitPrice、AsOfDateの順、データは1~2,000行。余分な列、結合セル、非表示行・列、空欄、数式、Excelエラーを拒否します。
- ItemCode:英大文字・数字・_・-で1~20文字の文字列。同一ファイル内では一意です。例「00002」も文字列で保存します。
- Quantity:0~1,000,000の整数を数値セルとして保存します。小数、真偽値、数値に見える文字列は受け付けません。
- UnitPrice:小数点以下2桁の文字列。例「19.90」。0.00~99999999.99を受け付け、通貨記号、桁区切り、追加の小数桁は拒否します。金額を浮動小数点へ変換せずDecimalで扱います。
- AsOfDate:実在する日付をYYYY-MM-DDの文字列として保存します。Excelの日付シリアル値や日時セルは、この例では対象外です。
たとえば、ExcelでA列・C列・D列を文字列として入力し、次の2行を作ります。Excelでの表示ではなく、実際に文字列として保存されていることを確認してください。
ItemCode | Quantity | UnitPrice | AsOfDate
SKU001 | 12 | 19.90 | 2026-10-20
00002 | 0 | 0.00 | 2026-02-28Python 3.11以降を想定します。承認された作業環境に必要なPythonパッケージをそろえるコマンド例は次のとおりです。実運用では互換性を確認したバージョンを固定します。pyodbcを省略するとmssql+pyodbc接続は動作しません。
python -m pip install pandas openpyxl sqlalchemy pyodbcこのコマンドでMicrosoftのODBCドライバーまで導入されるわけではありません。OSごとのODBC Driver公式導入案内を確認してください。本例はODBC Driver 18 for SQL Serverを指定します。
Python用テーブルを事前に準備する
次の2表は、検証用DBの所有者が別途準備する定義です。Pythonプログラムはテーブルを作成・置換せず、存在しない場合は失敗します。既存表が別の定義なら、その表を削除したり制約を緩めたりして例に合わせないでください。ウィザード用の表とも混用しません。
-- Schema reference for a dedicated test database, provisioned separately by its owner.
-- Do not run against production or replace existing tables to make this example work.
CREATE TABLE dbo.ExcelImportBatch (
BatchId char(64) NOT NULL,
ImportedRowCount int NOT NULL,
ImportedAt datetime2(0) NOT NULL
CONSTRAINT DF_ExcelImportBatch_ImportedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_ExcelImportBatch PRIMARY KEY (BatchId),
CONSTRAINT CK_ExcelImportBatch_ImportedRowCount CHECK (ImportedRowCount BETWEEN 1 AND 2000)
);
CREATE TABLE dbo.ExcelInventoryStage (
BatchId char(64) NOT NULL,
ItemCode varchar(20) COLLATE Latin1_General_100_BIN2 NOT NULL,
Quantity int NOT NULL,
UnitPrice decimal(10,2) NOT NULL,
AsOfDate date NOT NULL,
CONSTRAINT PK_ExcelInventoryStage PRIMARY KEY (BatchId, ItemCode),
CONSTRAINT FK_ExcelInventoryStage_Batch FOREIGN KEY (BatchId)
REFERENCES dbo.ExcelImportBatch (BatchId),
CONSTRAINT CK_ExcelInventoryStage_Quantity CHECK (Quantity BETWEEN 0 AND 1000000),
CONSTRAINT CK_ExcelInventoryStage_Price CHECK (UnitPrice >= 0)
);BatchIdは検証・正規化した値から計算するSHA-256です。ファイル名やZIPの保存日時ではないため、同じ内容を再保存した場合や行順を変えた場合でも、この例では同じ取り込み単位になります。既存バッチは件数と全行の値を照合し、合致した場合だけ追加を省略します。
この設計が防ぐのは同じ内容のバッチの再取り込みです。一部の値を変えたファイルは別バッチになるため、バッチをまたぐ業務上の重複排除や本番へのupsertを保証するものではありません。また、同一内容を別の業務イベントとして登録する要件がある場合は、別途イベントIDの設計が必要です。
実行用アカウントはこの2表に必要なSELECT/INSERT権限に絞り、テーブルの作成・削除や本番表の更新権限を与えない構成にします。両表はこの処理専用とし、管理表の削除やステージング値の書き換えを別処理で行わない前提です。保持期間・削除手順は運用として別途決めます。
入力検証からステージング保存までの完全なPython例
次をimport_inventory.pyとして保存します。引数に.xlsxだけを指定した場合はローカル検証のみで、DBには接続しません。--writeを付けた場合だけステージングへ送信します。SQL文の表名と列名は固定し、値はバインドパラメーターで渡しています。
"""Synthetic inventory example. Validation is local; --write opts into staging only."""
import argparse
from datetime import date
from decimal import Decimal
import hashlib
from io import BytesIO
import json
import os
from pathlib import Path
import re
import sys
from zipfile import ZipFile
import pandas as pd
from openpyxl import load_workbook
COLUMNS = ["ItemCode", "Quantity", "UnitPrice", "AsOfDate"]
MAX_ROWS = 2000
MAX_BYTES = 5 * 1024 * 1024
MAX_EXPANDED_BYTES = 50 * 1024 * 1024
class ImportRejected(Exception):
"""Messages contain fixed reason codes, never cell values or credentials."""
def read_inventory(path):
if path.suffix.lower() != ".xlsx":
raise ImportRejected("E_FILE_TYPE")
# Read once: validation and parsing use the same snapshot of the file.
with path.open("rb") as source:
payload = source.read(MAX_BYTES + 1)
if not payload or len(payload) > MAX_BYTES:
raise ImportRejected("E_FILE_SIZE")
with ZipFile(BytesIO(payload)) as archive:
entries = archive.infolist()
if len(entries) > 1000 or sum(e.file_size for e in entries) > MAX_EXPANDED_BYTES:
raise ImportRejected("E_ARCHIVE_SIZE")
if any(e.flag_bits & 1 for e in entries):
raise ImportRejected("E_ENCRYPTED_ARCHIVE")
# This is a bounded, trusted sample-file reader, not a hostile-file sandbox.
workbook = load_workbook(BytesIO(payload), data_only=False, keep_links=False)
try:
if workbook.sheetnames != ["Inventory"]:
raise ImportRejected("E_SHEET")
sheet = workbook["Inventory"]
if sheet.sheet_state != "visible" or sheet.merged_cells.ranges:
raise ImportRejected("E_LAYOUT")
if any(d.hidden for d in sheet.row_dimensions.values()) or any(
d.hidden for d in sheet.column_dimensions.values()
):
raise ImportRejected("E_HIDDEN_DATA")
if sheet.max_column != 4 or not 2 <= sheet.max_row <= MAX_ROWS + 1:
raise ImportRejected("E_DIMENSIONS")
if [cell.value for cell in sheet[1]] != COLUMNS:
raise ImportRejected("E_HEADER")
for row in sheet.iter_rows():
if any(cell.value is None for cell in row):
raise ImportRejected("E_EMPTY_CELL")
if any(cell.data_type in {"f", "e"} for cell in row):
raise ImportRejected("E_FORMULA_OR_EXCEL_ERROR")
finally:
workbook.close()
frame = pd.read_excel(
BytesIO(payload), sheet_name="Inventory", engine="openpyxl",
dtype=object, keep_default_na=False, na_filter=False, nrows=MAX_ROWS + 1,
)
if list(frame.columns) != COLUMNS or not 1 <= len(frame) <= MAX_ROWS:
raise ImportRejected("E_TABLE_SHAPE")
records = []
seen = set()
for code, quantity, price, day in frame.itertuples(index=False, name=None):
if not isinstance(code, str) or re.fullmatch(r"[A-Z0-9_-]{1,20}", code) is None:
raise ImportRejected("E_ITEM_CODE")
if code in seen:
raise ImportRejected("E_DUPLICATE_ITEM_CODE")
seen.add(code)
if isinstance(quantity, bool) or not isinstance(quantity, (int, float)):
raise ImportRejected("E_QUANTITY_TYPE")
amount = Decimal(str(quantity))
if not amount.is_finite() or amount != amount.to_integral_value() or not 0 <= amount <= 1000000:
raise ImportRejected("E_QUANTITY_RANGE")
if not isinstance(price, str) or re.fullmatch(r"(?:0|[1-9][0-9]{0,7})\.[0-9]{2}", price) is None:
raise ImportRejected("E_UNIT_PRICE")
if not isinstance(day, str) or re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", day) is None:
raise ImportRejected("E_DATE_FORMAT")
try:
as_of_date = date.fromisoformat(day)
except ValueError:
raise ImportRejected("E_DATE_VALUE") from None
records.append({"ItemCode": code, "Quantity": int(amount),
"UnitPrice": Decimal(price), "AsOfDate": as_of_date})
return sorted(records, key=lambda item: item["ItemCode"])
def batch_id_for(records):
# Canonical business values, not the XLSX ZIP bytes or its file name.
canonical = [[r["ItemCode"], r["Quantity"], format(r["UnitPrice"], ".2f"),
r["AsOfDate"].isoformat()] for r in records]
value = json.dumps(["inventory-v1", canonical], ensure_ascii=True, separators=(",", ":"))
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def make_engine():
# Import only on the write path; validation does not connect to a database.
from sqlalchemy import create_engine
from sqlalchemy.engine import URL
keys = ["SQLSERVER_HOST", "SQLSERVER_DATABASE", "SQLSERVER_USER", "SQLSERVER_PASSWORD"]
if not all(os.environ.get(key) for key in keys):
raise ImportRejected("E_CONNECTION_CONFIG")
url = URL.create(
"mssql+pyodbc", host=os.environ["SQLSERVER_HOST"], port=1433,
database=os.environ["SQLSERVER_DATABASE"],
username=os.environ["SQLSERVER_USER"], password=os.environ["SQLSERVER_PASSWORD"],
query={"driver": "ODBC Driver 18 for SQL Server",
"Encrypt": "yes", "TrustServerCertificate": "no"},
)
return create_engine(url, echo=False, hide_parameters=True,
connect_args={"timeout": 15})
def check_staged_rows(connection, batch_id, records):
from sqlalchemy import text
result = connection.execute(text("""
SELECT ItemCode, Quantity, UnitPrice, AsOfDate
FROM dbo.ExcelInventoryStage WHERE BatchId = :batch_id
"""), {"batch_id": batch_id})
# Do not allow corrupted/incompatible pre-existing batches to use unbounded memory.
observed = result.fetchmany(MAX_ROWS + 1)
result.close()
expected = [tuple(r[column] for column in COLUMNS) for r in records]
if sorted(tuple(row) for row in observed) != sorted(expected):
raise ImportRejected("E_STAGING_MISMATCH")
def write_staging(engine, records):
from sqlalchemy import text
batch_id = batch_id_for(records)
outcome = "INSERTED"
# No AUTOCOMMIT. Both tables must already exist with the documented schema.
with engine.begin() as connection:
connection.execute(text("SET XACT_ABORT ON"))
previous = connection.execute(text("""
SELECT ImportedRowCount FROM dbo.ExcelImportBatch WITH (UPDLOCK, HOLDLOCK)
WHERE BatchId = :batch_id
"""), {"batch_id": batch_id}).scalar_one_or_none()
if previous is not None:
if previous != len(records):
raise ImportRejected("E_BATCH_MISMATCH")
check_staged_rows(connection, batch_id, records)
outcome = "ALREADY_IMPORTED"
else:
connection.execute(text("""
INSERT INTO dbo.ExcelImportBatch (BatchId, ImportedRowCount)
VALUES (:batch_id, :row_count)
"""), {"batch_id": batch_id, "row_count": len(records)})
insert = text("""
INSERT INTO dbo.ExcelInventoryStage
(BatchId, ItemCode, Quantity, UnitPrice, AsOfDate)
VALUES (:BatchId, :ItemCode, :Quantity, :UnitPrice, :AsOfDate)
""")
for offset in range(0, len(records), 200):
connection.execute(insert, [dict(r, BatchId=batch_id)
for r in records[offset:offset + 200]])
check_staged_rows(connection, batch_id, records)
# Only report success after the context manager has committed.
return outcome
def main():
parser = argparse.ArgumentParser()
parser.add_argument("xlsx", type=Path)
parser.add_argument("--write", action="store_true", help="Write only to the two staging tables")
args = parser.parse_args()
engine = None
try:
records = read_inventory(args.xlsx)
if not args.write:
print(f"VALIDATED rows={len(records)}; database not contacted")
return 0
engine = make_engine()
outcome = write_staging(engine, records)
print(f"{outcome} rows={len(records)}")
return 0
except ImportRejected as error:
# Every ImportRejected in this script has a fixed, non-sensitive message.
print(str(error), file=sys.stderr)
return 1
except Exception:
# Do not log driver errors, input values, paths, connection URLs or secrets.
print("E_OPERATION: inspect restricted diagnostics; do not assume commit failed", file=sys.stderr)
return 1
finally:
if engine is not None:
engine.dispose()
if __name__ == "__main__":
sys.exit(main())接続情報の設定と実行方法
SQLSERVER_HOST、SQLSERVER_DATABASE、SQLSERVER_USER、SQLSERVER_PASSWORDを、実行環境の設定として用意します。例示名は順に「検証用サーバーのDNS名」「検証用DB名」「専用の取り込みユーザー」「その秘密情報」です。実際のパスワードをソースコード、Excel、共有ノート、シェル履歴へ書き込まないでください。環境変数自体も秘密保管庫ではないため、必要に応じて組織のシークレット管理から実行時に注入します。
例はSQL認証・TCP 1433を前提とします。Windows統合認証やMicrosoft Entra認証、別ポートを使う環境では、組織の接続方式に合わせた変更と再検証が必要です。SQLAlchemyのSQL Server接続仕様を参照してください。URL.createで接続情報を構成するため、パスワード中の特殊文字をURL文字列へ手動で埋め込む必要はありません。
Encrypt=yes、TrustServerCertificate=noを明示しています。証明書の信頼やサーバー名の不一致で接続できないときは、その原因を管理者と確認します。接続を通すためだけに暗号化を無効化したり、証明書検証を省略したりしないでください。ODBC Driverの暗号化設定も確認します。
# まず合成データをローカル検証。DB接続なし。
python import_inventory.py synthetic_inventory.xlsx
# 検証用DB・権限・接続設定を確認してから行う明示的な書き込み。
python import_inventory.py synthetic_inventory.xlsx --write成功時は件数と状態だけを表示します。INSERTEDはトランザクション終了後の保存成功、ALREADY_IMPORTEDは同一バッチが既存で内容も一致、VALIDATEDはファイル検証だけの完了です。入力値、接続URL、パスワード、ドライバー例外の全文は表示しません。
トランザクション・再実行・検証をどう扱うか
- 同一トランザクション:管理表への登録、200行単位の挿入、登録値の読み戻しをengine.begin()の中で行います。200行ごとの処理はコミット単位ではありません。途中の例外は外へ伝播させ、コンテキストがロールバックを行う設計です。
- SQL側の実行時エラー:SET XACT_ABORT ONを使います。ただし、接続断やコミット応答が戻らないケースを「必ず未保存」と判定することはできません。結果不明時に別のバッチIDで登録し直さず、同じ検証済みファイルを使って既存バッチを照合します。
- 同時実行:管理表のUPDLOCK/HOLDLOCKと主キーを用いて同一キーの確認・登録を保護します。一般的なSELECT→INSERTだけでは競合を防げません。ロック待ちやデッドロックは起こり得るため、実環境の分離レベル・負荷で検証します。失敗を隠す無制限リトライは行いません。
- 保存値の確認:最大2,000行という対象に限り、保存した4列を読み戻して入力全件と比較します。件数だけではなくDecimalや日付の値も照合し、異なれば成功扱いにしません。
- 既存データの保護:既存バッチが壊れていた場合は停止します。自動で削除・修復・置換しません。管理表を消すと再実行判定の前提が失われるため、障害対応でも安易に削除しないでください。
根拠となるAPIとSQL仕様:SQLAlchemyのトランザクション管理、SET XACT_ABORT、SQL Serverのテーブルヒント。本例の同時実行・ロールバックはSQL Server実機では未検証です。
pandasのto_sqlを使う場合の注意
pandasのto_sqlは便利ですが、if_exists=”append”は重複防止ではなく追記です。同じ処理を例外処理の前後で2回実行すると、二重登録または制約違反になります。また、replaceは既存表を削除してから作成するため、本番表へ安易に使ってはいけません。
to_sqlを使う設計なら、既存表・型・制約を確認し、同じトランザクション中のSQLAlchemy Connectionをconに渡し、schema、index=False、必要なdtypeを明示します。処理済み判定と検証も同じトランザクションへ組み込みます。appendでも表が存在しない場合に作成され得るため、CREATE権限を与えないことや事前確認が必要です。
本記事の完全例では、テーブルの自動作成を避け、列の対応を固定するため、pandasは読み込みに用い、書き込みにはパラメーター付きINSERTを使いました。to_sqlの戻り値だけを正確な保存件数として扱わず、DBで検証してください。詳細:pandas.DataFrame.to_sql、pandas.read_excel。
よくあるエラーと対応
- Excelプロバイダーが見つからない:ウィザードとACEのビット数・Office導入形態を確認します。古いACEの再配布版を無条件に追加しないでください。Python経路ではACEは使用しません。
- pyodbcやODBC Driverが見つからない:Pythonの実行環境とインストール先を確認します。pyodbcとOS側のODBCドライバーは別々です。
- E_HEADER/E_DIMENSIONS/E_EMPTY_CELL:列名、余分な列、件数、空欄を確認します。書式だけ残った行・列も厳格な検証では対象になることがあります。元ファイルではなく作業用コピーを修正します。
- E_UNIT_PRICE/E_DATE_FORMAT:表示形式ではなくセル値の型を確認します。この例では金額と日付を所定の文字列で保存します。実データに合わせて緩める場合は、丸め方や曖昧な日付の解釈を先に決めます。
- E_STAGING_MISMATCH/E_BATCH_MISMATCH:成功扱いにせず、権限のある担当者が対象バッチと表定義を確認します。エラーを消すために既存データを上書きしません。
- E_OPERATION:接続、権限、制約、タイムアウトなどの可能性があります。権限を制限した診断環境で原因を調べ、入力値や接続情報を公開ログへ出さないでください。コミット済みか不明なら、保存状況の照合が先です。
実運用前の確認リスト
- 合成データで新規取り込みと同一内容の再実行を試し、行数が増えないことを確認する。
- 不正な日付、範囲外の数値、重複コード、数式、空欄、長すぎる値が書き込み前に拒否されることを確認する。
- 検証用DBで制約違反や途中の失敗を発生させ、管理表とステージングが部分保存されないことを確認する。
- 同一バッチの並列実行、ロック待ち、接続断、コミット結果不明のケースを検証する。モデルテストではSQL Serverのロックやドライバーの動作までは確認できない。
- アカウントの最小権限、TLS証明書、表定義、ログの秘匿性、バックアップ、管理表の保持方針を確認する。
- 本番反映の業務ルールと承認を別途用意する。ステージングへの保存完了を、本番移行の完了と混同しない。
今回のローカル検証ではpandas 2.2.3とopenpyxl 3.1.5を使用し、合成.xlsxの検証とPython制御フローのモデルを合わせて34テストが通過しました。SQLAlchemyとpyodbcは検証環境に未導入のため、接続、SQL構文の実行、SQL Serverの型変換・制約・競合・実ロールバックは未検証です。パッケージやSQL Serverを追加導入して検証したものではありません。
まとめ
単発の取り込みなら、対応プロバイダーを確認したウィザードが選択肢になります。繰り返し処理するなら、型・件数・キーを明示して検証し、ステージングと取り込み管理表を使って再実行を制御します。どちらも、入力の確認、明示的な列対応、保存後の照合、本番反映の分離を基本にしてください。


コメント