ข้ามไปยังเนื้อหา

Virtual Environments & pip

ทุกโปรเจกต์ Python ควรรันอยู่ภายใน environment ที่แยกออกมาของตัวเอง หากไม่มี ทุกการติดตั้งจะลงไปยัง Python installation ที่ใช้ร่วมกันตัวเดียว ซึ่งแพ็กเกจจากโปรเจกต์ต่าง ๆ จะชนกันและความขัดแย้งของเวอร์ชันจะสะสมอย่างเงียบ ๆ บทนี้จะสอนเครื่องมือสมัยใหม่ก่อน — uv — แล้วค่อยแสดง venv/pip ที่อยู่ข้างใต้ เพราะ uv จัดการทั้งสองอย่างให้คุณ แต่ทุกโปรเจกต์ที่คุณเจอที่เก่ากว่าปี 2024 ยังใช้เครื่องมือดิบเหล่านี้ตรง ๆ อยู่

บทนี้ไม่มีปุ่ม Run — uv/venv/pip ต้องรันในเครื่อง: uv init myapp && cd myapp && uv add requests

uv เป็น binary ตัวเดียวที่แทนที่ venv + pip + pip-tools + pyenv + pipx มัน resolve และติดตั้งเร็วกว่า pip ราวสิบเท่า เพราะเขียนด้วย Rust และ cache อย่างจริงจังข้ามโปรเจกต์

Terminal window
# create a new project: pyproject.toml, .python-version, src/ layout, git init
uv init myapp && cd myapp
# pyproject.toml (generated by uv init)
[project]
name = "myapp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []

uv init ยังเขียนไฟล์ .python-version ด้วย — output จริงบนเครื่องนี้:

$ cat .python-version
3.14

ทุก uv run/uv sync ในไดเรกทอรีนี้ (และ subdirectory) จะเล็ง interpreter เวอร์ชันนั้นทันที โดยที่คุณไม่ต้องตั้ง environment variable หรือ activate อะไรเลย

Terminal window
uv add requests # resolve, download, install, record in pyproject.toml + uv.lock
uv remove requests # uninstall + remove from pyproject.toml + relock
uv run python main.py # run inside the project's environment -- no activation needed

รันจริง เพิ่ม requests ในโปรเจกต์ใหม่:

$ uv add requests
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 6 packages in 367ms
Installed 5 packages in 10ms
+ certifi==2026.7.22
+ charset-normalizer==3.5.1
+ idna==3.20
+ requests==2.34.2
+ urllib3==2.8.0

uv add สร้าง .venv ให้เองโดยที่คุณไม่เคยรัน python -m venv หรือ activate อะไรเลย uv run ใช้ environment นั้นโดยอัตโนมัติ:

$ echo 'import requests
print(requests.__version__)' > main.py
$ uv run python main.py
2.34.2

การลบก็ทำงานแบบเดียวกันในทิศตรงกันข้าม — uv remove requests ถอนการติดตั้งแพ็กเกจ และ dependency ทุกตัวที่ requests ต้องการเพียงผู้เดียว แล้วเขียน pyproject.toml ใหม่:

$ uv remove requests
Resolved 1 package in 3ms
Uninstalled 5 packages in 12ms
- certifi==2026.7.22
- charset-normalizer==3.5.1
- idna==3.20
- requests==2.34.2
- urllib3==2.8.0

uv add ยังเขียน uv.lock ด้วย ซึ่งบันทึกทุกแพ็กเกจที่ resolve แล้ว เวอร์ชันของมัน และ hash ของไฟล์ — สำหรับทุกแพลตฟอร์ม ไม่ใช่แค่ตัวที่คุณใช้อยู่ นี่คือสิ่งเดียวที่ requirements.txt ธรรมดาไม่เคยทำได้ดี

$ head -12 uv.lock
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]

uv sync สร้าง environment ขึ้นใหม่จาก uv.lock ให้ตรงเป๊ะ บนเครื่องไหนหรือ CI runner ไหนก็ได้:

$ uv sync
Resolved 6 packages in 3ms
Checked 5 packages in 0.11ms

uv lock --check ตรวจว่า pyproject.toml กับ uv.lock ยังตรงกันอยู่ โดยไม่ติดตั้งอะไร — คือ check ที่ควรรันใน CI ก่อน uv sync --frozen:

$ uv lock --check
Resolved 6 packages in 3ms

commit ทั้ง pyproject.toml และ uv.lock เข้า version control อย่า commit .venv/ เด็ดขาด

บางแพ็กเกจเป็นเครื่องมือที่คุณ รัน (ruff, mypy, httpie) ไม่ใช่ library ที่โค้ดคุณ import uv tool install จะติดตั้งมันลง environment แยกของตัวเองแล้วเชื่อม executable เข้า PATH ของคุณ; uvx รันเครื่องมือครั้งเดียวแบบชั่วคราว ไม่ติดตั้งถาวรเลย — คำสั่งของคอร์สนี้เอง (uvx ruff, uvx mypy) ก็ใช้แบบนี้เป๊ะ ๆ

$ uv tool install ruff
Resolved 1 package in 268ms
Installed 1 package in 4ms
+ ruff==0.16.8
Installed 1 executable: ruff
$ uvx ruff --version
ruff 0.16.8
$ uv python list --only-installed
cpython-3.14.7-macos-aarch64-none /opt/homebrew/bin/python3.14 -> ../Cellar/[email protected]/3.14.7/bin/python3.14
cpython-3.14.6-macos-aarch64-none /Users/.../uv/python/cpython-3.14-macos-aarch64-none/bin/python3.14
cpython-3.13.14-macos-aarch64-none /opt/homebrew/bin/python3.13 -> ../Cellar/[email protected]/3.13.14/bin/python3.13

uv สามารถดาวน์โหลดและจัดการสำเนา CPython ของตัวเองได้ (cpython-3.14.6 ข้างบนมาจาก uv python install ที่ถูกเรียกอัตโนมัติโดย uv add เมื่อไม่มี interpreter ไหนตรงกับ requires-python) — แยกขาดจาก system Python หรือ Homebrew install ใด ๆ โดยสิ้นเชิง uv python pin 3.14 จะเขียน .python-version; เพื่อนร่วมทีมและ CI runner ทุกตัวที่ใช้ uv เหมือนกันจะได้ interpreter ตัวเดียวกันเป๊ะ ไม่ใช่ “อะไรก็ตามที่ python3 บังเอิญชี้ไปถึง”

สำหรับสคริปต์หรือขั้นตอน CI ที่ยังคาดหวัง interface แบบ pip เป๊ะ ๆ uv pip คือ reimplementation ที่เร็วกว่ามากและใช้แทนกันได้ทันที ทำงานบน virtual environment เดียวกัน:

$ uv pip list
Package Version
------------------ ---------
certifi 2026.7.22
charset-normalizer 3.5.1
idna 3.20
requests 2.34.2
urllib3 2.8.0
$ uv pip install --dry-run tomli
Resolved 1 package in 630ms
Would download 1 package
Would install 1 package
+ tomli==2.4.1

uv pip ไม่อัปเดต pyproject.toml หรือ uv.lock — มันมีไว้เพื่อความเข้ากันได้กับ tooling แบบ pip เดิม ไม่ใช่สำหรับจัดการโปรเจกต์แบบ uv-native ให้เลือกใช้ uv add/uv remove สำหรับสิ่งที่คุณตั้งใจจะเก็บไว้จริง ๆ

uv จัดการ virtual environment แบบ venv และเรียก resolver ที่เข้ากันได้กับ pip ให้คุณ การเข้าใจเครื่องมือดิบสำคัญ เพราะโค้ด Python ส่วนใหญ่ที่คุณจะเจอ — tutorial, repo เก่า, CI config — ยังใช้มันตรง ๆ อยู่

Terminal window
# create a virtual environment in .venv/, with the stdlib venv module
python -m venv .venv

การ activate ไม่ใช่เวทมนตร์ — มันแค่เพิ่มไดเรกทอรี bin/ ของ environment ไว้หน้า PATH ของ shell คุณ เพื่อให้ python และ pip แบบไม่ระบุ path ชี้ไปยังสำเนาที่แยกออกมา แทนที่จะเป็น system interpreter:

$ echo "$PATH" | cut -c1-90 # before activation
/opt/homebrew/opt/postgresql-client/bin:/Users/avetavos/google-cloud-sdk/bin:/Users/av
$ source .venv/bin/activate
$ echo "$PATH" | cut -c1-90 # after activation -- .venv/bin prepended
/private/tmp/.../plainvenv/.venv/bin:/opt/homebrew/opt/postgresql-client/bin:/Users/av

ไม่มีอะไรอื่นเปลี่ยนเลย: ไม่มี process ใหม่ ไม่มี interpreter ถูกสลับให้ใต้ดินโดยไม่รู้ตัว deactivate (function ของ shell ที่ activate นิยามไว้) แค่คืนค่า PATH เดิม

$ which python
.../plainvenv/.venv/bin/python
$ which pip
.../plainvenv/.venv/bin/pip
$ python -m pip --version
pip 26.2.1 from .../plainvenv/.venv/lib/python3.14/site-packages/pip (python 3.14)
$ pip --version
pip 26.2.1 from .../plainvenv/.venv/lib/python3.14/site-packages/pip (python 3.14)

ภายใน venv ที่ active อยู่และสร้างมาถูกต้อง ทั้งสองแบบเหมือนกัน เพราะ pip บน PATH คือ pip ของ interpreter ตัวนั้นเอง มันจะต่างกันทันทีที่ PATH โกหกคุณ — การ activate ที่พัง, pip ที่ติดตั้งด้วย sudo บัง pip ของ venv เอง หรือสคริปต์ที่ลืม activate ก่อน python -m pip ติดตั้งเข้า interpreter ที่รันมันเสมอ เพราะ Python จะ resolve module เทียบกับ sys.executable ไม่ใช่เทียบกับ pip ตัวไหนก็ตามที่ PATH เก่าบังเอิญเจอ ห้ามรัน sudo pip install เด็ดขาด — มันติดตั้งเข้า system Python ที่ tooling ของ macOS/Linux เองยังพึ่งพาอยู่ ด้วยสิทธิ์ root ข้าม virtual environment ไปทั้งหมด; system Python ที่พังจาก sudo pip ที่ผิดพลาดอาจทำ OS tooling ที่ไม่เกี่ยวข้องพังไปด้วย

$ python -m pip install -e . --quiet
$ python -c "import edemo; print(edemo.greet())"
v1
$ sed -i '' 's/v1/v2 (edited, no reinstall)/' src/edemo/__init__.py
$ python -c "import edemo; print(edemo.greet())"
v2 (edited, no reinstall)

-e (editable/development mode) ติดตั้ง link ไปยัง source tree ของคุณแทนที่จะ copy ไฟล์ — การแก้ไขมีผลตั้งแต่ import ครั้งถัดไป โดยไม่ต้อง reinstall นี่คือวิธีที่คุณพัฒนา package ที่กำลังถูกใช้จากสคริปต์หรือ test suite อีกตัวไปพร้อมกัน

$ python -m pip install requests --quiet
$ pip freeze
certifi==2026.7.22
charset-normalizer==3.5.1
idna==3.20
requests==2.34.2
urllib3==2.8.0

pip freeze > requirements.txt จับภาพเวอร์ชันที่แน่นอนของ สิ่งที่ติดตั้งอยู่ตอนนี้พอดี — ไม่มี file hash ไม่มี per-platform wheel resolution ไม่มีการแยกระหว่าง “ฉันขอสิ่งนี้เอง” กับ “สิ่งนี้ถูกดึงมาเป็น dependency ของอย่างอื่น” uv.lock บันทึก hash และ wheel เฉพาะแพลตฟอร์มของทุกแพ็กเกจโดยดีฟอลต์; requirements.txt ธรรมดาจะมี hash ก็ต่อเมื่อคุณรัน pip-compile --generate-hashes หรือ uv export --no-hashes=false เองเท่านั้น — requirements.txt ส่วนใหญ่ในโลกจริงไม่มี hash เลย ซึ่งหมายความว่า pip install -r requirements.txt จะยินดีติดตั้งแพ็กเกจที่ถูกปลอมแปลงที่มี เลขเวอร์ชัน ถูกต้องแต่เป็น ไฟล์ คนละไฟล์

flowchart TD
A["uv init myapp"] --> B["uv add requests"]
B --> C[".venv created + pyproject.toml + uv.lock written"]
C --> D["uv run ... / uv sync"]
D -->|"reproduces exactly"| E["teammate or CI: uv sync --frozen"]
F["python -m venv .venv"] --> G["source .venv/bin/activate (PATH only)"]
G --> H["python -m pip install ..."]
H --> I["pip freeze > requirements.txt (no hashes, no per-platform lock)"]
ขั้นตอนแบบ uv-native เทียบกับขั้นตอนแบบ venv+pip ที่อยู่ข้างใต้

uv ไม่ใช่ package format ใหม่ — มันคือ implementation ที่เร็วกว่าของงานสามอย่างเดิมที่ pyenv + venv + pip แยกกันทำ: ดึง interpreter มา, สร้าง environment ที่แยกออกมา, resolve และติดตั้งแพ็กเกจเข้าไป uv.lock คือสิ่งที่ output ของ pip freeze พยายามจะเป็นมาตลอด: คำอธิบายของ dependency graph ทั้งหมด ที่ pin ด้วย hash สำหรับ ทุก target platform ไม่ใช่แค่ภาพนิ่งของ site-packages บนเครื่องเดียวตอนนี้

สิ่งที่ได้ประโยชน์ต้นทุน
uv add/uv runไม่ต้องสร้างหรือ activate venv เอง เป็น project-scoped โดยโครงสร้างต้องติดตั้งเครื่องมือเพิ่ม (แม้จะเป็น static binary ตัวเดียว)
uv.lockpin ด้วย hash, ครอบคลุมทุกแพลตฟอร์ม, reproduce ได้ทุกที่แก้ด้วยมือแบบ requirements.txt ไม่ได้; ต้องใช้ uv (หรือ uv export) เพื่ออ่าน
uv pip compatibility layerใช้กับ CI/สคริปต์แบบ pip ได้ทันทีด้วย resolver ที่เร็วกว่าข้าม bookkeeping ของ pyproject.toml/uv.lock — ไม่เหมาะสำหรับจัดการโปรเจกต์แบบ uv-native
venv + pip ตรง ๆใช้แค่ stdlib + แพ็กเกจ PyPI ตัวเดียว ไม่ต้องติดตั้งอะไรเพิ่มactivate เอง, ไม่มี lockfile ในตัว, ติดตั้งเข้า interpreter ผิดตัวได้ง่าย
pip freeze > requirements.txtง่าย อ่านได้ด้วยตาคนไม่มี hash โดยดีฟอลต์, ไม่มี per-platform resolution, ปนกันระหว่าง direct กับ transitive dependency
  • “การ activate venv จะเริ่ม Python process ใหม่” มันแค่แก้ PATH (และตัวแปร shell อีกไม่กี่ตัว) ใน shell ปัจจุบัน ของคุณ — ไม่มี process ไหนเริ่มจนกว่าคุณจะรัน python ครั้งถัดไป
  • “pip กับ python -m pip ใช้แทนกันได้เสมอ” ทั้งสองจะตรงกันก็ต่อเมื่อ PATH ชี้ pip ไปยังสำเนาของ interpreter ที่ active อยู่ถูกต้อง; python -m pip คือเวอร์ชันที่ PATH เก่าหรือถูกบังไม่สามารถหลอกได้
  • “uv.lock เป็นตัวเลือก เหมือนที่ requirements.txt เคยเป็นมาตลอด” uv sync/uv run อ่านมันเพื่อ reproduce environment ของคุณเป๊ะ; การลบมันทิ้ง (หรือไม่ commit) จะทำให้ปัญหา “works on my machine” ที่ uv.lock มีไว้เพื่อกำจัดกลับมาอีก
  • “requirements.txt pin แพ็กเกจที่แน่นอนและตรวจสอบได้เสมอ” มัน pin แค่ เลขเวอร์ชัน ตามธรรมเนียมเท่านั้น — ถ้าไม่มี --generate-hashes ก็ไม่มีอะไรหยุด pip จากการติดตั้งไฟล์คนละไฟล์ภายใต้ version string เดียวกันจาก index ที่ถูกแฮ็กหรืออัปโหลดซ้ำ
  • “sudo pip install เป็นทางแก้ error สิทธิ์ที่สมเหตุสมผล” error สิทธิ์ตอนติดตั้งแพ็กเกจแทบทุกครั้งหมายความว่าคุณไม่ได้อยู่ใน virtual environment ทางแก้คือสร้างและ activate สักตัว ไม่ใช่ติดตั้งด้วยสิทธิ์ root เข้า system Python

ก่อนเชื่อสคริปต์ setup หรือ requirements.txt ที่ generate มา ให้ตรวจ:

  • sudo pip install ... ที่ไหนก็ตามในสคริปต์หรือ README — แปลว่ามีคนข้าม virtual environment ไป ไม่ใช่ไม่จำเป็นน้อยลง
  • requirements.txt ที่มี entry ไม่ pin เวอร์ชัน (requests ไม่มีเวอร์ชันเลย) อยู่ข้าง ๆ คำอ้างว่า environment “reproducible”
  • subprocess.run(["pip", "install", ...]) ที่ถูกเรียกจากในโค้ดแอปตอน runtime — dependency ควรอยู่ใน pyproject.toml/requirements.txt ติดตั้งก่อนโปรแกรมรัน ไม่ใช่เป็น side effect ของการรันโปรแกรม
  • สคริปต์ setup ที่ activate venv แล้วเรียก pip (ไม่ใช่ python -m pip) ในอีก subprocess ที่ shell=False — activate มีผลแค่กับ PATH ของ shell ปัจจุบัน เท่านั้น ดังนั้น pip เปล่าใน subprocess ใหม่อาจไปชี้ที่ system pip แบบเงียบ ๆ

🐛 หา bug ในโค้ดนี้

import subprocess
import sys
def ensure_dependency(package: str) -> None:
# BUG: installs into whatever interpreter/environment happens to be
# active when this function runs, and does it every time the program
# starts -- not once, at setup, into a lockfile-controlled environment.
subprocess.run(["pip", "install", package], check=True)
def main() -> None:
ensure_dependency("requests")
import requests
print(requests.get("https://example.com", timeout=5).status_code)
if __name__ == "__main__":
main()
เฉลย

การติดตั้ง dependency จากใน main() ด้วย subprocess.run(["pip", "install", ...]) หมายความว่าทุกครั้งที่โปรแกรมรันสามารถติดตั้งหรืออัปเกรดแพ็กเกจแบบเงียบ ๆ ได้ ไม่มี lockfile ไม่มี pin ไม่มีบันทึกว่าอะไรเปลี่ยนไป — นี่คือ failure mode แบบ “works on my machine” เป๊ะ ๆ ที่ uv.lock/requirements.txt มีไว้ป้องกัน มันยังเรียก pip เปล่าแทนที่จะเป็น python -m pip (หรือ sys.executable) ดังนั้นใน subprocess ที่ PATH ต่างหรือหายไปมันอาจติดตั้งเข้า interpreter ผิดตัวไปเลย หรือ fail ไปเลย ทางแก้: ประกาศ requests เป็น dependency ใน pyproject.toml (uv add requests) ติดตั้งครั้งเดียวเป็นขั้นตอน setup (uv sync) แล้วให้โปรแกรมแค่ import requests และ fail แบบชัดเจนตอนเริ่มถ้ามันหายไป

uv add requests ทำอะไรที่ python -m venv .venv && pip install requests ไม่ทำ?
การ activate virtual environment (source .venv/bin/activate) เปลี่ยนอะไรจริง ๆ?
การ import ครั้งที่สองพิมพ์อะไร และทำไม?
$ python -m pip install -e .
$ python -c "import edemo; print(edemo.greet())"
v1
$ sed -i '' 's/v1/v2/' src/edemo/__init__.py
$ python -c "import edemo; print(edemo.greet())"
ทำไม pip freeze > requirements.txt ธรรมดาถึงให้การรับประกัน reproducibility ที่อ่อนกว่า uv.lock?
โค้ดแบบนี้ผิดตรงไหน?
def ensure_dependency(package: str) -> None:
    subprocess.run(["pip", "install", package], check=True)

def main() -> None:
    ensure_dependency("requests")
    import requests
ทำไม sudo pip install requests ถึงถูกมองว่าอันตราย ไม่ใช่แค่ไม่จำเป็น?