第4部 実務とセキュリティ
第8章 PythonからDBを使う
sqlite3コンソールで打ってきたSQLを、今度はプログラムから実行します。Pythonと組み合わせた瞬間、データベースは「CSVの取り込み」「日次の集計」「小さな業務ツール」を自動でこなす実務の道具になります。
🎯 この章で学ぶこと
- sqlite3モジュールの基本の流れ — connect → cursor → execute → fetch → close
- プレースホルダ(
?)が「便利機能」ではなく「絶対条件」である理由 - CSVファイルをデータベースに取り込み、SQLで一瞬で集計する
- 問い合わせ管理ツール(登録・一覧・更新・検索)を関数分割で作る
- ORM(SQLAlchemy)という選択肢と、生SQLを先に学ぶべき理由
- 接続情報(パスワード・DBパス)をコードに直書きしない管理方法
8.1 sqlite3モジュールの基本
PythonからSQLiteを使うのに、追加のインストールは一切不要です。sqlite3 モジュールは標準ライブラリに含まれています(第1章でSQLiteを選んだ理由のひとつがこれでした)。基本の流れは、どのデータベース・どの言語でもほぼ同じで、次の5ステップです。
- connect — データベースに接続する
- cursor — SQLを実行するための「カーソル」を得る
- execute — SQLを実行する
- fetchall / fetchone — 結果を受け取る(SELECTの場合)
- close — 接続を閉じる
第3章から使っている company.db を読んでみましょう。まずは対話モードで1行ずつ確かめます。
$ python3
>>> import sqlite3
>>> conn = sqlite3.connect("company.db")
>>> cur = conn.cursor()
>>> cur.execute("SELECT name, salary FROM employees WHERE salary >= 300000")
<sqlite3.Cursor object at 0x7f3a2c1b8d40>
>>> cur.fetchall()
[('佐藤 花子', 380000), ('鈴木 一郎', 320000), ('高橋 美咲', 350000), ('伊藤 健', 300000)]
>>> conn.close()
結果はタプルのリストで返ってきます。1行が1タプル、列の並びはSELECTで書いた順です。スクリプトにするとこうなります。
import sqlite3
conn = sqlite3.connect("company.db")
try:
cur = conn.cursor()
cur.execute("SELECT id, name, salary FROM employees ORDER BY salary DESC")
for emp_id, name, salary in cur.fetchall():
print(f"{emp_id}: {name} ({salary:,}円)")
finally:
conn.close() # 何があっても必ず閉じる
try / finally を毎回書くのは面倒なので、実務では contextlib.closing と with文を組み合わせて「ブロックを抜けたら自動で閉じる」書き方がよく使われます。
import sqlite3
from contextlib import closing
with closing(sqlite3.connect("company.db")) as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM employees")
print(cur.fetchone()[0]) # → 6
# ここに来た時点で conn.close() 済み
紛らわしいのですが、with sqlite3.connect(...) as conn: と書いた場合、ブロックを抜けたときに行われるのはコミット(失敗時はロールバック)であって、closeではありません。第5章で学んだトランザクションの自動管理です。「閉じる」目的なら上の contextlib.closing を使うか、明示的に conn.close() を呼んでください。
データを変更するSQLを execute しただけでは、まだ確定していません。conn.commit() を呼んで初めてファイルに書き込まれます(第5章のトランザクションそのものです)。「Pythonから入れたはずのデータが消えた」の原因はほぼ100%コミット忘れです。
8.2 プレースホルダは絶対条件
ここが本章でいちばん重要な節です。SQLに「ユーザーが入力した値」を組み込むとき、f文字列や文字列連結を使ってはいけません。まず、やってはいけない例から見ます。
次のコードは、ユーザー入力をそのままSQL文に埋め込んでいます。name に ' OR '1'='1 のような文字列が入力されると、WHERE条件が無効化されて全社員のデータが返ります。ユーザー入力をSQLに文字列連結した瞬間に、それはSQLインジェクション脆弱性です。
# ダメな例: f文字列でSQLを組み立てている
name = input("社員名: ")
cur.execute(f"SELECT * FROM employees WHERE name = '{name}'") # 脆弱!
正しい書き方はプレースホルダです。SQL文の中に ? を置き、値は第2引数のタプルで渡します。
# 正しい例: ? プレースホルダ
name = input("社員名: ")
cur.execute("SELECT * FROM employees WHERE name = ?", (name,))
# 複数の値も同じ。? の数とタプルの要素数を合わせる
cur.execute(
"SELECT name, salary FROM employees WHERE dept_id = ? AND salary >= ?",
(20, 300000),
)
プレースホルダを使うと、値は「SQLの一部」ではなく「ただのデータ」としてDBMSに渡されます。入力に ' や OR が含まれていても、それは単なる文字列として検索されるだけで、SQL文の構造を変えることはできません。これがSQLインジェクションの根本的な対策です。Python × セキュリティ入門コースの第10章でSQLインジェクションの攻撃例を学んだ人は、「あの攻撃はプレースホルダで無力化できる」とつなげて覚えてください。攻撃と対策の全体像は次の第9章で改めて扱います。
「SQL文の文字列の中に、変数を展開する記法(f文字列・+・%・format)が現れたら、それはバグ」。値はすべて ? で渡す——例外はありません。なお、テーブル名や列名はプレースホルダにできないため、どうしても可変にしたい場合は「許可リストとの照合」で対応します(第9章)。
8.3 実践① CSVをデータベースに取り込む
実務でまず役に立つのが「Excel/CSVで受け取ったデータをDBに入れて、SQLで集計する」パターンです。例として、月次の注文データ orders.csv を取り込んでみます。
$ head -4 orders.csv
order_date,dept_id,amount
2026-06-01,20,150000
2026-06-02,10,32000
2026-06-02,30,98000
標準ライブラリの csv モジュールで読み、executemany で一括登録します。executemany は「同じSQLを、タプルのリストの件数ぶん繰り返す」メソッドで、1件ずつ execute をループするより速く、コードも短くなります。
import csv
import sqlite3
# 1. CSVを読んでタプルのリストにする
with open("orders.csv", newline="", encoding="utf-8") as f:
rows = [
(r["order_date"], int(r["dept_id"]), int(r["amount"]))
for r in csv.DictReader(f)
]
conn = sqlite3.connect("company.db")
# 2. 受け皿のテーブルを用意する(あれば何もしない)
conn.execute("""
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_date TEXT NOT NULL,
dept_id INTEGER,
amount INTEGER NOT NULL CHECK (amount >= 0)
)
""")
# 3. 一括INSERT(ここでもプレースホルダ)
conn.executemany(
"INSERT INTO orders (order_date, dept_id, amount) VALUES (?, ?, ?)", rows
)
conn.commit()
print(f"{len(rows)}件を取り込みました")
# 4. 取り込んだそばからSQLで集計
cur = conn.execute("""
SELECT d.name, COUNT(*), SUM(o.amount)
FROM orders o
JOIN departments d ON o.dept_id = d.id
GROUP BY d.name
ORDER BY SUM(o.amount) DESC
""")
for dept, cnt, total in cur.fetchall():
print(f"{dept}: {cnt}件 / 合計 {total:,}円")
conn.close()
$ python3 import_orders.py
120件を取り込みました
営業部: 58件 / 合計 6,420,000円
情報システム部: 34件 / 合計 2,150,000円
総務部: 28件 / 合計 812,000円
Excelなら「ピボットテーブルを作って、範囲を選び直して……」と手作業だった月次集計が、スクリプト1本の再実行で終わるようになりました。第4章で学んだGROUP BYと第7章のJOINが、Pythonと組み合わさって初めて「自動化」に化けるのを体感してください。
8.4 実践② 問い合わせ管理ツールを作る
仕上げに、第6章でテーブル設計をした「社内問い合わせ管理」を、動くCLIツールにします。機能は登録(add)・一覧(list)・完了への更新(done)・キーワード検索(search)の4つ。完成コードを先に載せます(約100行)。
#!/usr/bin/env python3
"""社内問い合わせ管理ツール ticket.py"""
import sqlite3
import sys
from contextlib import closing
from datetime import datetime
DB_PATH = "company.db"
STATUSES = ("open", "in_progress", "closed")
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # 列名でアクセスできるようにする
return conn
def init_db():
with closing(get_conn()) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
requester TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'closed')),
created_at TEXT NOT NULL
)
""")
conn.commit()
def add_ticket(title, requester):
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with closing(get_conn()) as conn:
cur = conn.execute(
"INSERT INTO tickets (title, requester, created_at) VALUES (?, ?, ?)",
(title, requester, now),
)
conn.commit()
print(f"登録しました (id={cur.lastrowid})")
def list_tickets(status=None):
sql = "SELECT id, title, requester, status, created_at FROM tickets"
params = ()
if status is not None:
sql += " WHERE status = ?"
params = (status,)
sql += " ORDER BY id"
with closing(get_conn()) as conn:
rows = conn.execute(sql, params).fetchall()
if not rows:
print("該当するチケットはありません")
return
for r in rows:
print(f"[{r['id']:3d}] {r['status']:<11} {r['created_at']} "
f"{r['title']} ({r['requester']})")
def update_status(ticket_id, status):
if status not in STATUSES:
print(f"statusは {STATUSES} のいずれかです")
return
with closing(get_conn()) as conn:
cur = conn.execute(
"UPDATE tickets SET status = ? WHERE id = ?", (status, ticket_id)
)
conn.commit()
if cur.rowcount == 0:
print(f"id={ticket_id} のチケットは存在しません")
else:
print(f"id={ticket_id} を {status} に更新しました")
def search_tickets(keyword):
pattern = f"%{keyword}%" # LIKE用のパターンは「値」なのでこれはOK
with closing(get_conn()) as conn:
rows = conn.execute(
"SELECT id, title, status FROM tickets "
"WHERE title LIKE ? ORDER BY id",
(pattern,),
).fetchall()
for r in rows:
print(f"[{r['id']:3d}] {r['status']:<11} {r['title']}")
def usage():
print("使い方: python3 ticket.py add <件名> <依頼者>")
print(" python3 ticket.py list [open|in_progress|closed]")
print(" python3 ticket.py done <id>")
print(" python3 ticket.py search <キーワード>")
def main():
init_db()
args = sys.argv[1:]
if len(args) == 3 and args[0] == "add":
add_ticket(args[1], args[2])
elif args and args[0] == "list":
list_tickets(args[1] if len(args) >= 2 else None)
elif len(args) == 2 and args[0] == "done":
update_status(int(args[1]), "closed")
elif len(args) == 2 and args[0] == "search":
search_tickets(args[1])
else:
usage()
if __name__ == "__main__":
main()
動かすとこうなります。
$ python3 ticket.py add "プリンタが印刷できない" "鈴木 一郎"
登録しました (id=1)
$ python3 ticket.py add "VPNに接続できない" "高橋 美咲"
登録しました (id=2)
$ python3 ticket.py list
[ 1] open 2026-07-09 09:12 プリンタが印刷できない (鈴木 一郎)
[ 2] open 2026-07-09 09:15 VPNに接続できない (高橋 美咲)
$ python3 ticket.py done 1
id=1 を closed に更新しました
$ python3 ticket.py search VPN
[ 2] open VPNに接続できない
読みどころを3つ挙げます。
- SQLはすべてプレースホルダ — 件名・依頼者・キーワード、外から来る値は例外なく
?で渡しています。search_ticketsの%{keyword}%はf文字列ですが、組み立てているのは「LIKEに渡す値」であってSQL文ではないので安全です。 - CHECK制約とPython側チェックの二重防御 — statusの値は第6章で学んだCHECK制約がDB側で守り、Python側でも
STATUSESで検証しています。「アプリのチェックはすり抜けられることがある。最後の砦はDBの制約」という役割分担です。 - row_factoryで可読性を上げる —
sqlite3.Rowを設定するとr['title']のように列名で読めます。タプルの添字r[1]より、列を増やしたときの事故が減ります。
8.5 ORMという選択肢
実務のWeb開発では、SQLを直接書く代わりにORM(Object-Relational Mapper)というライブラリを使うことがよくあります。Pythonでの定番はSQLAlchemyで、テーブルをクラス、行をオブジェクトとして扱えます。雰囲気だけ見てください。
# SQLAlchemyの雰囲気(このコースでは深入りしません)
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
engine = create_engine("sqlite:///company.db")
with Session(engine) as session:
stmt = select(Employee).where(Employee.salary >= 300000)
for emp in session.scalars(stmt):
print(emp.name) # SQLを書かずに済んでいる
便利そうに見えますし、実際に便利です。しかし学ぶ順序は「生SQLが先、ORMが後」を強く勧めます。ORMは裏で結局SQLを生成しており、生SQLを知らないままORMだけ使うと、遅いクエリが生成されていることや、第7章で学んだN+1問題が起きていることに気づくことすらできないからです。逆に、SQLと実行計画を読める人にとって、ORMは安心して使える時短ツールになります。
本章の例ではDBパスを DB_PATH = "company.db" と書きましたが、MySQLやPostgreSQLに接続する実務コードでは、接続文字列にホスト名・ユーザー名・パスワードが含まれます。これをソースコードに直書きしたままGitリポジトリにpushして認証情報が漏れる——これは情報漏えい事故の「定番中の定番」です。公開リポジトリを機械的に巡回して認証情報を探す攻撃者は実在します。対策は、①パスワード類は環境変数(os.environ["DB_PASSWORD"])や設定ファイルから読む、②設定ファイル(.env など)は必ず .gitignore に入れてコミット対象から外す、③万一pushしてしまったら「履歴から消す」だけでなく直ちにパスワードを変更する(履歴を消しても複製済みかもしれない)、の3点です。Python × セキュリティ入門コースで学んだ「秘密情報の扱い」は、DB接続でこそ本番を迎えます。
まとめ
- Pythonの
sqlite3は標準ライブラリ。connect → cursor → execute → fetchall/fetchone → close が基本の流れで、変更系はcommit()を忘れない with conn:はコミット/ロールバックの自動化であってcloseではない。閉じるのはcontextlib.closingか明示的なclose()- ユーザー入力をSQLに文字列連結した瞬間にSQLインジェクション脆弱性になる。値は例外なく
?プレースホルダで渡す csv+executemanyでCSVを取り込めば、Excelで手作業だった集計がSQL1本になる- ORM(SQLAlchemy)は便利だが、生SQLを知らずに使うと遅いクエリやN+1問題に気づけない。学ぶ順序は生SQLが先
- 接続情報はコードに直書きせず、環境変数や.gitignore済みの設定ファイルで管理する
練習問題
次のコードには重大な脆弱性があります。何が問題かを説明し、安全なコードに書き直してください。
dept = input("部署ID: ")
min_salary = input("最低給与: ")
cur.execute(
f"SELECT name FROM employees "
f"WHERE dept_id = {dept} AND salary >= {min_salary}"
)
解答を見る
問題点は、ユーザー入力(dept と min_salary)をf文字列でSQLに直接埋め込んでいることです。たとえば最低給与に 0 OR 1=1 と入力されると条件が無効化され、全社員の情報が漏れます。さらに 0; DROP TABLE employees のような入力を試みられる恐れもあります(SQLインジェクション)。プレースホルダを使って書き直します。
dept = input("部署ID: ")
min_salary = input("最低給与: ")
cur.execute(
"SELECT name FROM employees WHERE dept_id = ? AND salary >= ?",
(int(dept), int(min_salary)),
)
値はタプルで渡し、SQL文自体は固定の文字列にします。ついでに int() で数値に変換しておけば、数値でない入力はこの時点で ValueError になり、入力検証(第9章で学ぶ多層防御の一層)も兼ねられます。
company.db を使い、「部署ごとの人数と平均給与」を表示するスクリプト dept_report.py を書いてください。部署名で表示し(第7章のJOINを使います)、どの部署にも所属しない社員(渡辺 由紀)が集計から漏れてもよいこととします。
解答を見る
import sqlite3
from contextlib import closing
with closing(sqlite3.connect("company.db")) as conn:
cur = conn.execute("""
SELECT d.name, COUNT(*), AVG(e.salary)
FROM employees e
JOIN departments d ON e.dept_id = d.id
GROUP BY d.name
ORDER BY AVG(e.salary) DESC
""")
for dept, cnt, avg_salary in cur.fetchall():
print(f"{dept}: {cnt}名 / 平均 {avg_salary:,.0f}円")
実行例:
$ python3 dept_report.py
総務部: 2名 / 平均 327,500円
情報システム部: 1名 / 平均 350,000円
営業部: 2名 / 平均 310,000円
SQLは固定文字列なのでプレースホルダは不要です(外から来る値がないため)。もし渡辺 由紀のようなdept_idがNULLの社員も含めたい場合は、JOIN を LEFT JOIN(employeesを基準にした外部結合)に変え、部署名のNULLを「未所属」と表示する工夫をします(第7章参照)。