MySQL 資料庫的操作

MySQL 是目前世界上使用最多的資料庫系統之一,如何在 Python 中操作 MySQL 資料是學習 Python 相當重要的課題。

📚 教案總覽:這份文件分成兩部分

內容對應章節資料庫 / 資料表
第一部分
基礎
連線 → 建表 → CRUD 四指令一 ~ 八pythondb / scores
第二部分
實戰進階
參數化查詢、批次寫入、交易、資料表設計、專案實作、GUI 整合九 ~ 十八class / students

第一部分,學會「能動」。
第二部分整合 vault 內三支實際程式,學會「能上線」—— 補足第一部分沒教、但實務上不做就會出事的東西(尤其是 SQL Injection 防護)。

搭配的三支程式(都在 vault 內):

程式說明對應章節
generate_students.py學生假資料產生器(手刻素材)→ generate_students十四
generate_students_faker.py同上的 Faker 改寫版十五
11. 視窗應用程式/assets/student_crud.pytkinter 圖形介面 CRUD十七

第一部分 基礎


一、事前準備:架設 MySQL 伺服器

建議的架站工具

在 Windows 上可以使用 Uniform ServerXAMPP 等架站機,方便學習時操作。

以 Uniform Server(https://www.uniformserver.com/)為例,下載後只要執行管理程式,即可在介面中:

功能位置
修改管理者密碼MySQLChange MySQL password(預設值 1qaz@wsx
修改連接埠MySQLChange MySQL port(預設 6033
啟動/停止 MySQL主畫面 Stop MySQL / Start MySQL
開啟網頁管理工具MySQL UtilitiesphpMyAdmin

建立資料庫

開始練習前要先建立資料庫:

  1. 開啟 phpMyAdmin 管理工具
  2. 在首頁按左方 新增
  3. 於「建立新資料庫」欄位輸入 pythondb 作為資料庫名稱
  4. 編碼選 utf8_general_ci(建議改用 utf8mb4_unicode_ci,見下方提醒)
  5. 建立 鈕完成

二、連接到資料庫

安裝模組

pip install pymysql

匯入與連線

import pymysql

連線語法:

連接物件 = pymysql.connect(host=伺服器位置, port=埠位, user=使用者名稱,
                          passwd=密碼, charset='utf8', db=資料庫名稱)

範例 —— 伺服器 localhost、埠位 6033、使用者 root、密碼 1qaz@wsx、資料庫 pythondb

conn = pymysql.connect(host='localhost', port=6033, user='root',
                       passwd='1qaz@wsx', charset='utf8mb4', db='pythondb')

💡 現代寫法建議(實測 PyMySQL 2.2.8)

使用的 passwddb 仍可運作,但已被標記為棄用,實際執行時可能會跳出:

DeprecationWarning: 'db' is deprecated, use 'database'

建議改用新參數名,行為完全相同:

conn = pymysql.connect(host='localhost', port=6033, user='root',
                       password='1qaz@wsx', charset='utf8mb4', database='pythondb')
(舊)建議(新)
passwd=password=
db=database=
charset='utf8'charset='utf8mb4'

charset='utf8' 在 MySQL 中其實是 utf8mb3(每字最多 3 bytes),存不了 emoji 和部分罕用中文字utf8mb4 才是真正完整的 UTF-8。


三、執行 SQL 指令的標準流程

要執行 SQL 指令,需用 cursor() 方法建立 cursor 物件來執行。若有新增、更新、刪除等動作,還要執行 commit() 提交到資料庫,最後關閉連線。

with 連線物件.cursor() as cursor物件:
    cursor物件.execute(sql字串)
    連線物件.commit()
連線物件.close()

三個關鍵動作

  1. cursor() — 建立操作物件(用 with 可自動關閉)
  2. commit() — 寫入類動作(新增/更新/刪除)沒 commit 就不會真的存進去
  3. close() — 關閉連線

純查詢(select不需要 commit()


四、建立資料表

語法

sql字串 = """
CREATE TABLE 資料表名稱 (
欄位名稱一  資料型態一,
欄位名稱二  資料型態二,
...
)
"""

常見資料型態

資料型態說明資料型態說明
int整數timestamp時間戳記
char(n)文字,長度固定為 nfloat浮點數
varchar(n)文字,長度為 n(可變boolean布林值

char(n) vs varchar(n)

char(20) 不管實際存幾個字,一律佔 20 格(不足補空白);varchar(20) 則是用多少佔多少,上限 20。存姓名這類長度不固定的資料用 varchar 比較省空間。

主索引欄(自動編號)

通常第一個欄位是主索引欄,可設定為自動產生、不重複的流水號:

欄位名稱 int not null auto_increment primary key
關鍵字意義
not null此欄位不可以沒有值
auto_increment欄位值會自動加 1
primary key此欄位值不可重複

完整範例:mysqltable.py

pythondb 資料庫中新增 scores 資料表,定義座號 (ID)、姓名 (Name)、國文 (Chinese)、英文 (English)、數學 (Math) 五個欄位。

import pymysql
conn = pymysql.connect(host='localhost', port=6033, user='root', password='1qaz@wsx', charset='utf8mb4', database='pythondb')   # 連結資料庫
 
with conn.cursor() as cursor:
    sql = """
    CREATE TABLE IF NOT EXISTS scores (
        ID int NOT NULL AUTO_INCREMENT PRIMARY KEY,
        Name varchar(20),
        Chinese int(3),
        English int(3),
        Math int(3)
    );
    """
    cursor.execute(sql)      # 執行 SQL 指令
    conn.commit()            # 提交資料庫
conn.close()

程式說明

說明
4由連接物件新增操作物件
5–13建立新增資料表的 SQL 指令字串
14用操作物件執行 SQL 指令
15將更新提交資料庫
16關閉連接物件

CREATE TABLE IF NOT EXISTS

加上 IF NOT EXISTS,資料表已存在時不會報錯,只是跳過不建立。練習反覆執行時很實用。

int(3) 的括號數字不是「限制 3 位數」,而是「顯示寬度」,對能存的數值範圍完全沒有影響int 一律是 4 bytes)。MySQL 8.0.17 起已標記為棄用,新版直接寫 int 即可。

執行後可在 phpMyAdmin 看到產生的 scores 資料表結構(ID 欄位有 AUTO_INCREMENT、其餘欄位允許 NULL)。


五、MySQL 資料庫管理(CRUD)

建立資料表後,就可在資料表中新增、修改、刪除或查詢資料了。

5-1 新增資料 (INSERT)

SQL 語法

insert into 資料表 (欄位1, 欄位2, ...) values (值1, 值2, ...), ...

範例:mysqlinsert.py —— 在 scores 資料表中新增 5 筆資料

import pymysql
conn = pymysql.connect(host='localhost', port=6033, user='root', password='1qaz@wsx', 
charset='utf8mb4', database='pythondb') # 連結資料庫
 
with conn.cursor() as cursor:
    sql = """
    insert into scores (Name, Chinese, English, Math) values
    ('李大毛',95,92,80),
    ('林小明',82,83,61),
    ('黃小英',74,53,71),
    ('劉大樹',86,87,89),
    ('何美麗',89,73,95)
    """
    cursor.execute(sql)
    conn.commit()      # 提交資料庫

注意 ID 欄位不必列在 SQL 命令中,系統會自動產生(因為設了 AUTO_INCREMENT)。

執行後資料表內容:

IDNameChineseEnglishMath
1李大毛959280
2林小明828361
3黃小英745371
4劉大樹868789
5何美麗897395

5-2 查詢資料 (SELECT)

SQL 語法

select 欄位1, 欄位2 ... from 資料表 where 條件式

查詢傳回的資料需以 cursor 物件的兩個方法取出:

方法說明回傳型態
fetchall()取出全部資料巢狀 tuple
fetchone()取出第一筆資料單一 tuple

範例:mysqlquery.py

import pymysql
conn = pymysql.connect(host='localhost', port=6033, user='root', password='1qaz@wsx', 
charset='utf8mb4', database='pythondb') # 連結資料庫
 
with conn.cursor() as cursor:
    sql = "select * from scores"
    cursor.execute(sql)
    datas = cursor.fetchall()     # 取出所有資料
    print(datas)
    print('-' * 30)               # 畫分隔線
 
    sql = "select * from scores"
    cursor.execute(sql)
    data = cursor.fetchone()      # 取出第一筆資料
    print(data)

執行結果

((1, '李大毛', 95, 92, 80), (2, '林小明', 82, 83, 61), (3, '黃小英', 74, 53, 71), (4, '劉大樹', 86, 87, 89), (5, '何美麗', 89, 73, 95))
------------------------------
(1, '李大毛', 95, 92, 80)

為什麼中間要再 execute() 一次?

cursor 讀完資料後指標已經到底,不重新執行查詢的話,fetchone() 會取不到東西。這是初學最容易卡住的地方。

逐筆顯示的寫法

datas = cursor.fetchall()
for row in datas:
    print(row[0], row[1])      # 用索引取欄位:0=ID, 1=Name, 2=Chinese...

5-3 更新資料 (UPDATE)

SQL 語法

update 資料表 set 欄位1=值1, 欄位2=值2 ... where 條件式

範例:mysqlupdate.py —— 修改座號 4 號同學的國文成績為 98

import pymysql
conn = pymysql.connect(host='localhost', port=6033, user='root', password='1qaz@wsx', 
charset='utf8mb4', database='pythondb') # 連結資料庫
 
with conn.cursor() as cursor:
    sql = "update scores set Chinese = 98 where ID = 4"
    cursor.execute(sql)
    conn.commit()
 
    sql = "select * from scores where ID = 4"
    cursor.execute(sql)
    data = cursor.fetchone()
    print(data)

執行結果

(4, '劉大樹', 98, 87, 89)

⚠️ where 忘記寫的後果

update scores set Chinese = 98 沒有 where整張表每一筆的國文都會變成 98
delete from scores 沒有 where整張表資料全部清空
養成習慣:先用 select 加同樣的 where 確認範圍,再改成 update / delete


5-4 刪除資料 (DELETE)

SQL 語法

delete from 資料表 where 條件式

範例:mysqldelete.py —— 刪除座號 4 號同學的資料

import pymysql
conn = pymysql.connect(host='localhost', port=6033, user='root', password='1qaz@wsx', 
charset='utf8mb4', database='pythondb') # 連結資料庫
 
with conn.cursor() as cursor:
    sql = "delete from scores where ID = 4"
    cursor.execute(sql)
    conn.commit()
 
    sql = "select * from scores"
    cursor.execute(sql)
    data = cursor.fetchall()
    print(data)

執行結果(ID 4 已消失)

((1, '李大毛', 95, 92, 80), (2, '林小明', 82, 83, 61), (3, '黃小英', 74, 53, 71), (5, '何美麗', 89, 73, 95))

刪除後 ID 不會補號

ID 4 被刪掉後,剩下 1、2、3、5 —— AUTO_INCREMENT 不會重新編號、也不會回收已用過的號碼。這是正常行為,不是 bug。

書上補充:SQL 指令

此處僅列出 SQL 指令的最簡單使用方法,每一個 SQL 指令都有非常多參數,詳細 SQL 指令語法請參閱 SQL 指令書籍。


六、完整可執行範本

把上面拆開的片段組成一支可直接跑的程式:

import pymysql
 
conn = pymysql.connect(
    host='localhost', port=6033,
    user='root', password='1qaz@wsx',
    charset='utf8mb4', database='pythondb'
)
 
try:
    with conn.cursor() as cursor:
        # 建立資料表
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS scores (
                ID      int NOT NULL AUTO_INCREMENT PRIMARY KEY,
                Name    varchar(20),
                Chinese int,
                English int,
                Math    int
            )
        """)
 
        # 新增資料
        cursor.execute("""
            insert into scores (Name, Chinese, English, Math) values
            ('李大毛',95,92,80), ('林小明',82,83,61), ('黃小英',74,53,71)
        """)
        conn.commit()
 
        # 查詢資料
        cursor.execute("select * from scores")
        for row in cursor.fetchall():
            print(row)
finally:
    conn.close()      # 不論有沒有出錯都關閉連線

為什麼包 try / finally

中間任何一行出錯,conn.close() 仍會執行,不會留下沒關掉的連線。


七、常見錯誤排查

錯誤訊息原因解法
ModuleNotFoundError: No module named 'pymysql'沒安裝模組pip install pymysql
Can't connect to MySQL server on 'localhost'MySQL 服務沒啟動 / 埠位不對到 Uniform Server 按 Start MySQL;確認埠位 6033
Access denied for user 'root'@'localhost'帳號或密碼錯誤確認密碼(Uniform Server 預設 1qaz@wsx
Unknown database 'pythondb'資料庫還沒建立先到 phpMyAdmin 建立 pythondb
Table 'pythondb.scores' doesn't exist資料表還沒建立先執行建立資料表的程式
資料查得到,但重開程式就不見了忘記 conn.commit()新增/更新/刪除後一定要 commit
中文變成 ??? 或亂碼編碼不符連線與資料庫都用 utf8mb4
DeprecationWarning: 'db' is deprecated用了舊參數名改用 database= / password=

第二部分會遇到的錯誤(詳見九~十五)

錯誤訊息原因解法
Out of range value for column 'cID'整數型態容量不足(TINYINT 上限 255)ALTER TABLE ... MODIFY COLUMN cID SMALLINT UNSIGNED ...
Unknown character set: 'utf8_unicode_ci'把 collation 當成 charset 用CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
UnicodeEncodeError: 'cp950' codec ...Windows 終端機編碼$env:PYTHONIOENCODING="utf-8"
Not enough parameters for the SQL statement%s 數量與參數不符數一下佔位符與 tuple 元素個數
單參數查詢報錯(5) 不是 tuple寫成 (5,)(加逗號)
查詢結果莫名撈出全表字串拼接造成 SQL Injection改用 %s 參數化查詢
ModuleNotFoundError: No module named 'mysql'套件名與匯入名不同pip install mysql-connector-python,匯入 import mysql.connector

八、重點整理

記住這 8 條

  1. 安裝 pip install pymysql,匯入 import pymysql
  2. 連線四要素:host、port(6033)、user、password,再加 databasecharset
  3. 操作流程固定是 connect → cursor → execute → commit → close
  4. 新增/更新/刪除要 commit(),查詢不用
  5. AUTO_INCREMENT PRIMARY KEY 的欄位新增時不用給值
  6. 取資料:fetchall() 全部fetchone() 第一筆;重新取要execute() 一次
  7. update / delete 一定要加 where,否則整張表遭殃
  8. 現代寫法用 password= / database= / charset='utf8mb4'

第二部分 實戰進階

這部分整合自 generate_students

第一部分學的是「怎麼讓程式跑起來」,這部分是「怎麼讓程式能真的拿去用」。
換一個資料庫情境:class 資料庫的 students 資料表(欄位比 scores 複雜得多)。
相關實作可參考 vault 內的 11. 視窗應用程式/assets/student_crud.py(tkinter 圖形介面版 CRUD)。


九、換一個驅動:mysql-connector-python

第一部份上用 PyMySQL,但實務上另一個常見選擇是 mysql-connector-python(MySQL 官方出品)。

pip install mysql-connector-python
import mysql.connector          # 注意:匯入名稱是 mysql.connector,不是套件名

兩種驅動對照

PyMySQLmysql-connector-python
安裝pip install pymysqlpip install mysql-connector-python
匯入import pymysqlimport mysql.connector
連線pymysql.connect(...)mysql.connector.connect(...)
開發者社群(純 Python)MySQL 官方
佔位符%s%s
cursor() / execute() / commit() / close()✅ 相同✅ 相同

好消息:換驅動幾乎不用改程式

兩者都遵循 Python DB-API 2.0 規範,所以 cursor()execute()fetchall()commit()close() 的用法完全一樣,佔位符也都是 %s
實務上只有 importconnect() 那兩行要改。這正是統一規範的價值 —— 學會一種,另一種立刻會用。

連線設定獨立成字典

與其把參數寫死在 connect() 裡,實務上會抽成一個設定字典:

DB_CONFIG = {
    "host": "localhost",
    "port": 6033,
    "user": "root",
    "password": "1qaz@wsx",
    "database": "class",
    "charset": "utf8mb4",
}
 
conn = mysql.connector.connect(**DB_CONFIG)     # ** 把字典「解包」成關鍵字引數

**DB_CONFIG 是什麼意思

** 會把字典拆開成一個個關鍵字引數,等同於手寫:

mysql.connector.connect(host="localhost", port=6033, user="root", ...)

好處:要改連線只需要改一個地方,而且同一份設定可以給多個函式共用。

🔒 帳密不要寫死在程式裡

練習可以直接寫,但正式專案絕對不行 —— 程式一上傳 GitHub,密碼就公開了。
正確作法是讀環境變數:

import os
DB_CONFIG = {
    "host": os.getenv("DB_HOST", "localhost"),
    "user": os.getenv("DB_USER", "root"),
    "password": os.getenv("DB_PASSWORD"),     # 不給預設值,強迫必須設定
    "database": os.getenv("DB_NAME", "class"),
}

十、參數化查詢 ★★★(最重要)

這一節是整份文件最重要的地方

第一部分所有 SQL 都是寫死的字串。一旦 SQL 裡要放使用者輸入的值,用字串拼接就會產生 SQL Injection(SQL 資料隱碼攻擊) 漏洞。

先看問題:字串拼接有多危險

# ❌ 絕對不要這樣寫
name = input("請輸入姓名:")
sql = "select * from students where cName = '" + name + "'"
cursor.execute(sql)

如果使用者輸入的是:

' OR '1'='1

拼出來的 SQL 會變成:

select * from students where cName = '' OR '1'='1'

'1'='1' 永遠成立 → 整張表的資料全部被撈出來

更糟的輸入還能做到刪表:

'; DROP TABLE students; --

正確作法:用 %s 佔位符

把值交給驅動去處理,不要自己拼字串。

# ✅ 正確
name = input("請輸入姓名:")
cursor.execute("select * from students where cName = %s", (name,))

驅動會自動幫值加上引號並跳脫特殊字元,剛才的惡意輸入會被安全地當成「一個普通的字串」處理:

select * from students where cName = '\' OR \'1\'=\'1'

查不到這個姓名 → 回傳空結果,攻擊失效

四大指令的參數化寫法

# 新增
cursor.execute(
    "INSERT INTO students (cName, cSex, cBirthday, cEmail, cPhone, cAddr) "
    "VALUES (%s, %s, %s, %s, %s, %s)",
    ('王小明', 'M', '1995-08-20', 'ming@example.com', '0912345678', '台北市信義路100號')
)
 
# 查詢
cursor.execute("SELECT * FROM students WHERE cSex = %s AND cHeight > %s", ('M', 175))
 
# 更新
cursor.execute("UPDATE students SET cEmail = %s WHERE cID = %s", ('new@mail.com', 5))
 
# 刪除
cursor.execute("DELETE FROM students WHERE cID = %s", (5,))

⚠️ 三個新手最常犯的錯

1. %s 不要自己加引號

cursor.execute("... where cName = '%s'", (name,))   # ❌ 多了單引號
cursor.execute("... where cName = %s",   (name,))   # ✅

驅動會自己處理引號,自己加反而會壞掉。

2. 只有一個參數時,別忘了逗號

cursor.execute("... where cID = %s", (5))    # ❌ (5) 是整數不是 tuple
cursor.execute("... where cID = %s", (5,))   # ✅ 有逗號才是 tuple

3. 不要用 f-string 或 % 拼 SQL

cursor.execute(f"select * from students where cName = '{name}'")   # ❌ 同樣有漏洞
cursor.execute("select * from students where cName = '%s'" % name) # ❌ 同樣有漏洞

看到 SQL 字串裡出現 f"+% 拼接變數 → 一定是錯的。

%s 跟資料型態無關

不管要放的是字串、整數還是日期,一律用 %s,不是 %d%f。這跟 Python 的 % 格式化是兩回事,別搞混。


十一、批次寫入 executemany()

要一次寫入很多筆資料時,逐筆 execute() 會很慢。改用 executemany()

students = [
    ('張賢仁', 'M', '1992-06-15', 'john510@outlook.com',  '0928251692', '台北市忠孝東路12號'),
    ('盧瑋勝', 'M', '1984-09-28', 'alex193@hotmail.com',  '0965334527', '新北市中山路5巷3號'),
    ('江涵彤', 'F', '1989-12-12', 'barbara62@yahoo.com.tw','0935430198', '桃園市信義路88號'),
]
 
sql = ("INSERT INTO students (cName, cSex, cBirthday, cEmail, cPhone, cAddr) "
       "VALUES (%s, %s, %s, %s, %s, %s)")
 
cursor.executemany(sql, students)     # 一次送出全部
conn.commit()
print(f"成功新增 {cursor.rowcount} 筆")
execute()executemany()
資料格式一個 tuplelist of tuple
往返資料庫次數每筆一次合併送出
適用單筆大量寫入

cursor.rowcount

執行後可用 cursor.rowcount 取得受影響的筆數,很適合拿來回報結果或做驗證。


十二、交易與 rollback()

第一部分的範例只用了 commit()。但如果寫到一半失敗呢?

try:
    cursor.executemany(sql, students)
    conn.commit()                      # 全部成功才提交
    print(f"✅ 成功新增 {len(students)} 筆")
except Exception as e:
    conn.rollback()                    # ⭐ 出錯就全部撤回
    print(f"❌ 寫入失敗,已復原:{e}")
finally:
    cursor.close()
    conn.close()                       # 不論如何都要關閉

commitrollback 是一組的

  • commit() — 確認變更,正式寫入
  • rollback() — 撤回這次交易中所有還沒 commit 的變更

沒有 rollback 的話,100 筆資料寫到第 57 筆失敗 → 前 56 筆留在資料庫、後面沒進去 → 資料處於不完整狀態,很難收拾。

⚠️ 注意:rollback() 只在 InnoDB 這類支援交易的儲存引擎才有效(見下一節)。

完整標準樣板(建議背起來)

import mysql.connector
 
conn = None
try:
    conn = mysql.connector.connect(**DB_CONFIG)
    with conn.cursor() as cursor:
        cursor.execute("INSERT INTO students (cName, cSex) VALUES (%s, %s)", ('王小明', 'M'))
        conn.commit()
except mysql.connector.Error as e:
    if conn:
        conn.rollback()
    print(f"資料庫錯誤:{e}")
finally:
    if conn:
        conn.close()

十三、資料表設計進階

第一部分的 scores 只有 5 個欄位、型態單純。實務上的資料表會複雜得多 —— 以 students 為例:

CREATE TABLE IF NOT EXISTS students (
    cID       SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    cName     VARCHAR(20)       NOT NULL,
    cSex      ENUM('F','M')     NOT NULL DEFAULT 'F',
    cBirthday DATE              NOT NULL,
    cEmail    VARCHAR(100)      DEFAULT NULL,
    cPhone    VARCHAR(50)       DEFAULT NULL,
    cAddr     VARCHAR(255)      DEFAULT NULL,
    cHeight   TINYINT UNSIGNED  DEFAULT NULL,
    cWeight   TINYINT UNSIGNED  DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

新出現的語法

語法意義
ENUM('F','M')列舉型態,只允許這幾個值之一,填別的會被拒絕
DATE日期型態,格式 YYYY-MM-DD(Python 的 datetime.date 可直接對應)
NOT NULL必填
DEFAULT 'F'沒給值時使用的預設值
DEFAULT NULL允許空值(可不填)
UNSIGNED無號,不能存負數,但正數上限變兩倍
ENGINE=InnoDB儲存引擎,支援交易(rollback)與外鍵
COLLATE排序與比較規則(大小寫是否敏感等)

⭐ 整數型態的範圍(實務上最容易踩雷)

型態SIGNED 範圍UNSIGNED 範圍
TINYINT−128 ~ 1270 ~ 255
SMALLINT−32,768 ~ 32,7670 ~ 65,535
MEDIUMINT±約 838 萬0 ~ 16,777,215
INT±約 21 億0 ~ 約 42 億
BIGINT±約 922 京0 ~ 約 1,844 京

💥 經典事故: Out of range value for column 'cID'

cIDTINYINT UNSIGNED(上限 255),資料超過 255 筆時,AUTO_INCREMENT 會撞到天花板直接報錯。

修正方式(不用重建資料表):

ALTER TABLE students MODIFY COLUMN cID SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT;

教學重點:型態選擇不是隨便挑,要先估算「這張表最多會有幾筆」。學生資料用 SMALLINT(6 萬筆)通常夠;不確定就用 INT
反過來說,身高體重用 TINYINT UNSIGNED(0~255)就綽綽有餘,不需要浪費空間用 INT

⚠️ generate_students 原文的 SQL 有兩處要修正

1. DEFAULT CHARSET=utf8_unicode_ci 會直接報錯
utf8_unicode_cicollation(排序規則),不是 charset(字元集),兩者不能混用。執行會得到:

ERROR 1115 (42000): Unknown character set: 'utf8_unicode_ci'

正確寫法要分開指定:

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

順帶一提,原文的 DB_CONFIG 已經用 utf8mb4,但建表卻寫 utf8兩邊不一致也會造成中文/emoji 存取問題 —— 統一用 utf8mb4

2. ZEROFILLTINYINT(3) 的顯示寬度已棄用
MySQL 8.0.17 起兩者都被標記為 deprecated,新版直接寫 TINYINT UNSIGNED 即可。

3. 「INT 最大 ~21 億」是指 SIGNED
若加上 UNSIGNED,上限是約 42 億

為什麼 emoji 一定要 utf8mb4(實測位元組數)

字元UTF-8 位元組數utf8mb3 存得下?
3
𠀋(罕用字)4
😀4

MySQL 的 utf8 每字最多只吃 3 bytes,所以 4 bytes 的字元會被截斷或報錯。


十四、實戰專案:學生假資料產生器

完整說明見 generate_students,這裡整理可以教的程式技巧

四步驟流程

Step 1  讀取資料庫現有資料  →  建立重複檢查用的集合 (set)
Step 2  產生 N 筆不重複假資料  →  每筆立即加入集合,避免新資料互相重複
Step 3  executemany 批次寫入  →  失敗自動 rollback
Step 4  SELECT COUNT(*) 驗證結果

技巧 1:用 set 做重複檢查

def load_existing_data():
    """讀出現有資料,回傳三個集合供比對"""
    conn = mysql.connector.connect(**DB_CONFIG)
    with conn.cursor() as cursor:
        cursor.execute("SELECT cName, cEmail, cPhone FROM students")
        rows = cursor.fetchall()
    conn.close()
 
    return {
        "names":  {r[0] for r in rows},
        "emails": {r[1].lower() for r in rows if r[1]},   # 統一小寫再比對
        "phones": {r[2] for r in rows if r[2]},
    }

為什麼用 set 不用 list

listset
判斷 x in ...逐一比對,O(n)雜湊查找,O(1)
1000 筆資料查 1000 次約 100 萬次比對約 1000 次

這是教「為什麼要選對資料結構」的絕佳例子 —— 同樣的邏輯,換個型態就快幾百倍。

技巧 2:產生時即時同步集合

used_names = existing["names"].copy()
 
for _ in range(count):
    for attempt in range(1000):              # 重試上限,避免無窮迴圈
        name = generate_chinese_name(sex)
        if name not in used_names:
            used_names.add(name)             # ⭐ 立刻加入,避免新資料之間重複
            break
    else:
        print("素材組合已用盡,提前停止")
        break

這裡剛好複習 for-else

else 只在迴圈沒有被 break 時執行 —— 也就是「重試 1000 次都失敗」的情況。
(這正是 ITS 考題的 for-else 觀念,可以跟 00-ITS Python 考照重點總整理 互相對照。)

技巧 3:讓假資料「合理」而非純隨機

def generate_weight(sex, height):
    """依 BMI 反推體重,資料才會貼近真實"""
    bmi = random.uniform(18.5, 27.0)
    return round(bmi * (height / 100) ** 2)

若身高體重各自亂數,會產生「190 公分 45 公斤」這種不合理資料。
用 BMI 公式把兩者關聯起來,這是資料設計的思維,不只是寫程式。

技巧 4:argparse 命令列參數

import argparse
 
parser = argparse.ArgumentParser(description="學生假資料產生器")
parser.add_argument("-n", "--count", type=int, default=100, help="要新增的筆數")
args = parser.parse_args()
 
print(f"本次新增 {args.count} 筆")
python generate_students.py            # 預設 100 筆
python generate_students.py -n 50      # 指定 50 筆
python generate_students.py --help     # 查看說明

技巧 5:SELECT COUNT(*) 驗證

cursor.execute("SELECT COUNT(*) FROM students")
total = cursor.fetchone()[0]        # fetchone 回傳 tuple,取 [0]
print(f"📊 資料庫目前共有 {total} 筆學生資料")

素材組合數與瓶頸(手刻版)

欄位理論組合上限計算方式
姓名80,00050 姓 × (40 + 40×39) 名
Email約 880,000110 英文名 × 8 域名 × 1000 數字
手機約 30,000,00030 前綴 × 10⁶

姓名是瓶頸

當已產生的資料越接近 80,000,重試次數會急遽上升(生日悖論效應)。
實務建議單次產生不超過 5,000 筆
這也是很好的教學點:理論上限 ≠ 實際可用量


十五、更省力的作法:Faker 套件

vault 內的 generate_students_faker.py 是同一支程式的改寫版 —— 不自己維護素材列表,改用現成的 Faker 套件。

pip install faker mysql-connector-python
from faker import Faker
 
fake = Faker("zh_TW")        # ⭐ 指定台灣本地化

常用方法(實測 Faker 40.19.1 / zh_TW

方法產生內容實際輸出範例
fake.name_male()男性中文姓名陳金龍
fake.name_female()女性中文姓名莊慧玲
fake.address()台灣地址200 竹北龍山寺路7段8號之0
fake.user_name()英文帳號hsiu-chenlin
fake.numerify("######")依樣板填入隨機數字235116
fake.date_between(start_date=…, end_date=…)區間內隨機日期1985-08-23

兩種作法對照

手刻素材generate_students.pyFakergenerate_students_faker.py
相依套件無(只用 randompip install faker
程式長度長(素材常數佔一大半)短很多
資料真實度素材有限,會有重複感內建大量真實素材
可控性完全自訂(要哪些姓氏就哪些)依套件提供的為準
教學價值看得到組合邏輯與機率學會善用現成輪子

兩份都留著,教學價值不同

  • 先教手刻版 —— 學生才會理解「隨機資料是怎麼被組出來的」、為什麼會有組合上限
  • 再教 Faker 版 —— 對照之下體會「不要重造輪子」,同時學會查套件文件

這是很自然的一課:先知其所以然,再學會偷懶。

Faker.seed() 讓結果可重現

Faker.seed(42)
random.seed(42)

設定種子後,每次執行產生的資料完全相同(實測驗證):

Faker.seed(42); print([fake.name_male() for _ in range(3)])
# ['陳金龍', '莊文章', '陳俊雄']
 
Faker.seed(42); print([fake.name_male() for _ in range(3)])
# ['陳金龍', '莊文章', '陳俊雄']   ← 完全一樣

為什麼「可重現」很重要

測試程式時,如果每次資料都不一樣,出了 bug 根本無法重現。
設種子後就能穩定重播同一組資料,這是軟體測試的基本功。
平常產生正式假資料時再把 seed 註解掉即可。


十六、Windows 中文編碼問題

在 Windows 執行含中文或 emoji 的程式時,常會遇到:

UnicodeEncodeError: 'cp950' codec can't encode character ...

原因是 Windows 終端機預設用 cp950(Big5),而程式輸出是 UTF-8

解法一:執行前設定環境變數

# PowerShell
$env:PYTHONIOENCODING="utf-8"
python generate_students.py

解法二:在程式開頭處理

import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

這跟資料庫編碼是兩回事

問題發生位置解法
UnicodeEncodeError: cp950終端機顯示PYTHONIOENCODING=utf-8
資料庫中文變 ???資料庫儲存連線與建表都用 utf8mb4

兩個都要處理,缺一不可。


十七、整合實戰:圖形介面 CRUD

對應 vault 內的 11. 視窗應用程式/assets/student_crud.py
這支程式把 tkinter(第 11 章)MySQL(本章) 接在一起,是整個學習路徑的匯流點

分層架構

程式刻意分成兩層,這是本節最重要的觀念:

┌─────────────────────────────────────────┐
│  介面層 (UI layer) — StudentApp 類別      │
│    表單 (Entry/Combobox)                 │
│    按鈕列 (新增/修改/刪除/清除/重新整理)     │
│    清單 (Treeview)                       │
├─────────────────────────────────────────┤
│  資料層 (DB layer)                       │
│    get_connection() + 各 CRUD 的 SQL     │
│    全部使用參數化查詢 (%s)                 │
└─────────────────────────────────────────┘

為什麼要分層

介面(怎麼顯示)和資料(怎麼存取)是兩件不同的事。分開之後:

  • 想把 tkinter 換成網頁,資料層完全不用改
  • 想把 MySQL 換成 SQLite,介面層完全不用改

這支程式沒有再加 ORM 之類的中介層 —— 因為資料表單純、規模小,直接讀寫已經夠清楚
不過度設計本身也是要教的觀念。

連線策略:每次操作開新連線

def get_connection():
    return mysql.connector.connect(**DB_CONFIG)
每次開新連線(本程式)全域共用一條
效能較差較好
連線逾時不用擔心要處理重連
跨執行緒安全要加鎖
適用單機、低頻率的桌面工具高頻服務

教學重點:這是一個取捨(trade-off),不是「標準答案」。 要能說出為什麼在這個情境下選它。

四個操作的完整對應

# Retrieve — 載入清單
cur.execute('SELECT cID, cName, cSex, cBirthday, cEmail, cPhone, cAddr '
            'FROM students ORDER BY cID')
 
# Create — cID 是 auto_increment,不要自己給
cur.execute('INSERT INTO students (cName, cSex, cBirthday, cEmail, cPhone, cAddr) '
            'VALUES (%s, %s, %s, %s, %s, %s)', self._form_values())
conn.commit()
 
# Update — 六個 SET 值 + 一個 WHERE 值
cur.execute('UPDATE students SET cName=%s, cSex=%s, cBirthday=%s, '
            'cEmail=%s, cPhone=%s, cAddr=%s WHERE cID=%s',
            self._form_values() + (self.selected_id,))
conn.commit()
 
# Delete
cur.execute('DELETE FROM students WHERE cID=%s', (self.selected_id,))
conn.commit()

self._form_values() + (self.selected_id,) 這行很值得講

_form_values() 回傳 6 個元素的 tuple,後面用 + (值,) 串上第 7 個。
這正好複習兩件事:tuple 用 + 串接單元素 tuple 一定要有逗號
少了那個逗號 (self.selected_id) 就變成單純的值,串接會直接報錯。

值得學的四個實作細節

1. 用 selected_id 區分「新增模式」與「編輯模式」

self.selected_id = None    # None = 新增模式;有值 = 編輯該筆

同一組表單同時支援新增和修改,靠一個變數切換 —— 比開兩個視窗簡潔得多。

2. 空字串轉 None,才會存成 NULL

self.var_email.get().strip() or None      # 使用者留空 → None → 資料庫存 NULL

反向讀出來時再轉回空字串,避免畫面顯示 None

values=(cid, name, sex, birthday, email or '', phone or '', addr or '')

x or Nonex or '' 的小技巧

Python 中空字串是 falsy,所以 '' or None 得到 NoneNone or '' 得到 ''
這比寫 if x == '': x = None 精簡,是很實用的慣用寫法。

3. 寫入前先驗證,不要讓資料庫報錯

def _validate(self):
    if not self.var_name.get().strip():
        messagebox.showwarning('輸入錯誤', '姓名為必填欄位')
        return False
    try:
        date.fromisoformat(birthday)          # 檢查 YYYY-MM-DD 格式
    except ValueError:
        messagebox.showwarning('輸入錯誤', '生日格式須為 YYYY-MM-DD')
        return False
    return True

驗證的欄位對應資料表中 NOT NULL 的設計(cNamecBirthday)。
前端先擋,比讓資料庫丟例外的使用者體驗好得多。

4. 刪除前一定要確認

if not messagebox.askyesno('確認刪除', f'確定要刪除編號 {self.selected_id} 的資料嗎?'):
    return

刪除是不可逆的,一定要給反悔的機會。

三支程式的關係

程式角色用到的本章觀念
generate_students.py造資料(手刻素材)批次寫入、rollback、set 去重
generate_students_faker.py造資料(Faker 版)同上 + 套件運用、seed
student_crud.py用資料(圖形介面)參數化查詢、CRUD、分層架構

完整的學習動線

建表用產生器塞測試資料用 GUI 維護資料
三支程式串起來,就是一個小型系統的完整生命週期。


延伸作業

  1. students 資料表的 cID 改成 TINYINT,跑產生器塞 300 筆 → 觀察 Out of range 錯誤 → 再用 ALTER TABLE 修好
  2. 把第一部分的 scores 範例全部改寫成參數化查詢
  3. student_crud.py,找出裡面所有 %s 的用法,並說明各自對應哪個欄位
  4. 幫產生器加一個 -d / --delete 參數,可清空資料表(必須加確認提示
  5. student_crud.py 加上「依姓名搜尋」功能 —— ⚠️ 必須用參數化查詢
  6. 比較 generate_students.pygenerate_students_faker.py,說出各自的優缺點與適用場合
  7. student_crud.pyDB_CONFIG 改成讀環境變數,讓密碼不再寫死在程式裡

📎 SQL 四大指令速查

動作SQL要 commit?參數化寫法
新增insert into 表 (欄位...) values (值...)execute(sql, (v1, v2))
查詢select 欄位 from 表 where 條件execute(sql, (v,))
更新update 表 set 欄位=值 where 條件execute(sql, (新值, 條件值))
刪除delete from 表 where 條件execute(sql, (條件值,))
批次新增同新增executemany(sql, [(...), (...)])

📎 第二部分重點整理

再記這 8 條

  1. 兩種驅動都遵循 DB-API 2.0,換驅動只需改 importconnect()
  2. 連線設定抽成 DB_CONFIG 字典,用 connect(**DB_CONFIG) 解包
  3. SQL 裡有變數就一定要用 %s 參數化 —— 看到 f"+% 拼接 SQL 就是錯的
  4. %s 不分型態、不要自己加引號;單一參數要寫 (x,)
  5. 大量寫入用 executemany(),回報筆數用 cursor.rowcount
  6. commit()rollback() 是一組的,寫入務必包 try / except / finally
  7. 整數型態要先估算資料量TINYINT 255/SMALLINT 6.5 萬/INT 21 億
  8. utf8mb4 一路統一(連線 + 建表 + collation),Windows 另加 PYTHONIOENCODING