pandas: lọc, nhóm và nối dữ liệu đơn hàng
Xem thửSáng thứ Hai, Bình ở phòng vận hành của Giao Hàng Anh Tí mở báo cáo tuần: tỉ lệ đơn giao trễ của cả Phố Rôm Rả là 18,6%. Anh định chia đều 16 shipper tăng cường cho 8 quận, mỗi quận 2 người. Cùng lúc đó, Minh gửi báo cáo doanh thu theo nhóm khách: anh nối bảng đơn hàng với bảng khách xuất từ hệ thống CRM, và tổng giá trị đơn trong báo cáo là 28,18 tỷ đồng. Kế toán trả lời ngay: con số đúng là 27,5 tỷ. Minh kiểm tra lại tỉ lệ giao trễ trong báo cáo của mình, vẫn là 18,57%, khớp với số của Bình.
Hai chuyện có vẻ không liên quan, nhưng đều là chuyện thao tác bảng. Trước khi đọc tiếp, hãy ghi ra dự đoán của bạn: quận trễ nhiều nhất và quận trễ ít nhất chênh nhau khoảng bao nhiêu điểm phần trăm? Và nếu tỉ lệ trễ trong báo cáo của Minh vẫn đúng, thì 0,68 tỷ đồng thừa ra từ đâu?
Sau bài này, bạn có thể
- Lọc dòng bằng mặt nạ boolean và
.loc, chọn cột bằngdf["col"], và giải thích vì saoorders.itemskhông phải cộtitems. - Tính chỉ số theo nhóm bằng
groupbyvàaggtheo mô hình split, apply, combine, kể cả nhóm theo hai cột. - Nối hai bảng bằng
mergecóvalidate, kiểm tra số dòng sau khi nối, và giải thích vì sao khóa trùng làm sai tổng mà không làm sai tỉ lệ. - Nhận ra hai hành vi của pandas 3 khác các hướng dẫn cũ: gán qua hai lần ngoặc vuông không còn sửa bảng, và cột chữ có kiểu
str.
Cần biết trước: bài NumPy: mảng, vectorization và broadcasting (bài trước), đặc biệt mặt nạ boolean và mean theo trục.
Mức độ: Cơ bản · Thời gian: khoảng 25 phút · Từ khóa: pandas, DataFrame, index, groupby, merge, validate, Copy-on-Write
DataFrame là một bảng có chỉ mục
Mô hình tư duy thứ nhất: DataFrame là một bảng mà mỗi cột là một mảng NumPy có tên, và mọi cột dùng chung một chỉ mục (index). Chỉ mục là nhãn của từng dòng. Khi đọc orders.parquet, chỉ mục mặc định là 0, 1, 2, ..., 49.999. Một cột lấy riêng ra là một Series: mảng một trục cộng với chỉ mục đó.
Người quen Java có thể hình dung DataFrame như một List<Order> xoay ngang: thay vì 50.000 đối tượng 17 trường, pandas giữ 17 mảng 50.000 phần tử, nên mọi phép tính trên một cột là phép tính vectorized của bài trước. Với người quen SQL: groupby giống GROUP BY, merge giống JOIN; khác ở chỗ DataFrame có chỉ mục, và kết quả groupby lấy khóa nhóm làm chỉ mục mới (muốn thành cột lại thì gọi .reset_index()).
Chọn cột luôn dùng ngoặc vuông: orders["items"]. Cách viết orders.items trông tiện nhưng bị trùng tên với phương thức DataFrame.items() có sẵn, và bạn sẽ thấy kết quả thật ở bước 1.
Lọc dòng dùng lại mặt nạ boolean của bài NumPy: orders[orders["is_rain"]] giữ các dòng True. Nhiều điều kiện nối bằng &, |, ~ (và, hoặc, phủ định), mỗi điều kiện trong ngoặc tròn vì & ưu tiên cao hơn ==. Khi chọn cả dòng và cột, hoặc khi gán, dùng .loc[điều_kiện_dòng, tên_cột].
Split, apply, combine: một mô hình cho mọi phép nhóm
Mô hình tư duy thứ hai: mọi phép groupby gồm ba pha. Split (chia): gom các dòng có cùng giá trị khóa thành nhóm. Apply (áp dụng): tính một hàm trên từng nhóm độc lập, ví dụ mean của cột is_late. Combine (ghép): xếp kết quả của các nhóm thành một bảng mới, chỉ mục là khóa nhóm.
Trong Java, bạn sẽ viết orders.stream().collect(Collectors.groupingBy(Order::district, Collectors.averagingDouble(o -> o.isLate() ? 1 : 0))) và nhận về một Map. pandas làm cùng việc đó trong một dòng và trả về một bảng, nên có thể tiếp tục sắp xếp, lọc, nối. Vì is_late là True/False và True được tính là 1, trung bình của nó chính là tỉ lệ đơn trễ.
Animation dưới đây chạy ba pha trên đúng 50.000 đơn của bài. Hãy dự đoán khoảng cách giữa quận cao nhất và thấp nhất trước khi bấm, rồi bật biến thể để thấy điều gì xảy ra khi bỏ bước chia nhóm.
Đang tải Animation…
Con số 18,6% của Bình không sai, nhưng nó là trung bình của 8 quận trải từ 13,7% tới 25,6%. Câu trả lời cho nửa đầu câu hỏi mở bài: Khu Cong Nghiep trễ nhiều hơn Dau Pho 11,9 điểm phần trăm, gần gấp đôi. Chia đều shipper là bỏ phí người ở Dau Pho và thiếu người ở Khu Cong Nghiep.
Làm cùng mình
Mọi file nằm trong m01/c02/examples của kho lab, chạy từ chính thư mục đó. Bài có bốn bước; bước 2, 3 và 4 chứa các tình huống thất bại có thật.
Bước 1: xem bảng, lọc dòng, nhóm theo quận.
orders = pd.read_parquet(DATA)
print("rows, columns:", orders.shape, " index:", orders.index[:3].tolist())
print("orders.items is a", type(orders.items).__name__, "| orders['items'] is a", type(orders["items"]).__name__)
rainy_express = orders[(orders["is_rain"]) & (orders["service"] == "express")]
print("rainy express orders:", len(rainy_express), " late share:", round(rainy_express["is_late"].mean(), 3))
by_district = (
orders.groupby("district")
.agg(n=("order_id", "size"),
late_rate=("is_late", "mean"),
avg_minutes=("delivery_minutes", "mean"))
.sort_values("late_rate", ascending=False)
.round(3)
)
print(by_district)
print("index of result:", by_district.index.name, "| first label:", by_district.index[0])
rain_effect = orders.groupby(["district", "is_rain"])["is_late"].mean().unstack().round(3)
print(rain_effect.loc[["Dau Pho", "Khu Cong Nghiep"]])
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
type(orders.items) | Lấy thuộc tính tên items của DataFrame | Cho thấy cú pháp dấu chấm trùng tên phương thức |
orders[(...) & (...)] | Giữ các dòng mà cả hai mặt nạ đều True | Lọc đơn express trong ngày mưa |
.groupby("district") | Pha split: chia 50.000 dòng thành 8 nhóm | Khóa nhóm là cột quận |
.agg(n=(...), late_rate=(...), ...) | Pha apply: mỗi cặp là (cột, hàm), tên bên trái là tên cột kết quả | Tính nhiều chỉ số trong một lần duyệt |
groupby(["district", "is_rain"]) rồi .unstack() | Nhóm theo hai khóa, xoay khóa thứ hai thành cột | So ngày khô và ngày mưa cạnh nhau |
Kết quả [Đã chạy] Python 3.12, pandas 3.1, NumPy 2.5, CPU x86_64, ngày 04/11/2026:
rows, columns: (50000, 17) index: [0, 1, 2]
orders.items is a method | orders['items'] is a Series
rainy express orders: 4372 late share: 0.51
n late_rate avg_minutes
district
Khu Cong Nghiep 6115 0.256 78.877
Doi Thong 6172 0.227 75.870
Cho Moi 6045 0.195 71.551
Bai Bien 6337 0.186 70.092
Ben Song 6091 0.167 68.643
Xom Vui 6489 0.165 67.322
Cau Go 6473 0.157 66.680
Dau Pho 6278 0.137 63.100
index of result: district | first label: Khu Cong Nghiep
is_rain False True
district
Dau Pho 0.118 0.192
Khu Cong Nghiep 0.227 0.347
Đọc kết quả theo từng dòng. orders.items là một method, không phải cột. Đơn express trong ngày mưa trễ tới 51%, vì express chỉ hứa 60 phút còn mưa cộng thêm 12 phút. Bảng theo quận có chỉ mục là district, nên by_district.loc["Dau Pho"] lấy được dòng của Dau Pho bằng tên. Thứ tự tỉ lệ trễ khớp đúng thứ tự mức ùn tắc trong data card, từ Dau Pho (0 phút) tới Khu Cong Nghiep (15 phút). Bảng cuối cho thấy mưa làm tỉ lệ trễ ở Dau Pho tăng từ 11,8% lên 19,2%, ở Khu Cong Nghiep từ 22,7% lên 34,7%.
Nối bảng: một khóa trùng nhân bản cả dòng
Bảng đơn hàng chỉ có customer_id. Muốn tính tỉ lệ trễ theo nhóm khách, ta nối với bảng khách bằng merge, tương đương JOIN trong SQL. Với how="left", mọi đơn được giữ lại, mỗi đơn được ghép với mọi dòng ở bảng khách có cùng customer_id. Nếu mỗi khách có đúng một dòng, số dòng không đổi. Nếu một khách có hai dòng, mọi đơn của khách đó xuất hiện hai lần.
Quan hệ giữa hai bảng vì vậy phải được khai báo và kiểm tra. Đơn với khách là quan hệ nhiều đơn với một khách, viết là validate="many_to_one". Tham số này bắt pandas kiểm tra khóa bên phải là duy nhất trước khi nối, và báo MergeError nếu không. Sơ đồ sau là quy trình Minh nên theo trước mọi phép nối.
Đang tải sơ đồ…
Cách đọc hình: bắt đầu từ ô cyan, hai bảng cần nối. Ô cam đầu tiên hỏi khóa ở bảng bên phải có duy nhất không. Nếu có, nối luôn với validate (ô tím). Nếu không, phải có một quy tắc nghiệp vụ để bỏ trùng, ví dụ "giữ dòng mới nhất"; không có quy tắc thì dừng lại hỏi người quản lý dữ liệu (ô đỏ), vì chọn bừa một dòng cũng là bịa dữ liệu. Sau khi nối, ô cam cuối kiểm tra số dòng có bằng bảng bên trái không. Chỉ khi qua cả hai cửa kiểm tra mới dùng bảng kết quả (ô xanh lá).
Animation sau chạy đúng tình huống của Minh: bảng CRM xuất lại có 200 khách bị ghi hai dòng vì đổi quận nhà. Chế độ đầu là phép nối không kiểm tra; bật biến thể để thêm validate.
Đang tải Animation…
Bước 2: nối đúng, rồi nối với bảng có khóa trùng.
joined = orders.merge(customers, on="customer_id", how="left", validate="many_to_one")
print("orders:", len(orders), " joined:", len(joined))
print(joined.groupby("segment_true")["is_late"].mean().round(3).sort_values())
# bảng khách xuất lại từ CRM: 200 khách bị ghi hai dòng (đổi quận nhà)
moved = customers.sample(200, random_state=7).assign(home_district="Dau Pho")
crm = pd.concat([customers, moved], ignore_index=True)
print("crm rows:", len(crm), " unique customer_id:", crm["customer_id"].nunique())
bad = orders.merge(crm, on="customer_id", how="left")
print("joined with crm:", len(bad), " extra rows:", len(bad) - len(orders))
print("late rate before:", round(joined["is_late"].mean(), 4), " after:", round(bad["is_late"].mean(), 4))
print("order value (billion VND) before:", round(joined["order_value_vnd"].sum() / 1e9, 2),
" after:", round(bad["order_value_vnd"].sum() / 1e9, 2))
print("order_id unique after join:", bad["order_id"].is_unique)
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
orders.merge(customers, on="customer_id", how="left", validate="many_to_one") | Nối mỗi đơn với đúng một khách, kiểm tra khóa phải duy nhất | Bảng customers gốc sạch nên qua kiểm tra |
customers.sample(200, random_state=7) | Chọn ngẫu nhiên 200 khách, cố định seed | Mô phỏng 200 khách đổi quận nhà |
pd.concat([customers, moved], ignore_index=True) | Nối dọc hai bảng, đánh lại chỉ mục | Bảng CRM có cả dòng cũ và dòng mới |
bad["order_id"].is_unique | Kiểm tra mỗi đơn chỉ xuất hiện một lần | Dấu hiệu chắc chắn của dòng bị nhân bản |
Kết quả [Đã chạy] Python 3.12, pandas 3.1, NumPy 2.5, CPU x86_64, ngày 04/11/2026:
orders: 50000 joined: 50000
segment_true
office 0.165
student 0.167
family 0.170
shop 0.210
Name: is_late, dtype: float64
crm rows: 8200 unique customer_id: 8000
joined with crm: 51214 extra rows: 1214
late rate before: 0.1857 after: 0.1857
order value (billion VND) before: 27.5 after: 28.18
order_id unique after join: False
Nhóm khách cửa hàng (shop) trễ 21,0%, cao hơn ba nhóm còn lại (16,5% đến 17,0%). Sau đó là tình huống của Minh: bảng CRM có 8.200 dòng nhưng chỉ 8.000 khách, phép nối sinh thêm 1.214 dòng, đúng bằng số đơn của 200 khách bị ghi hai lần. Câu trả lời cho nửa sau câu hỏi mở bài: tỉ lệ trễ vẫn 18,57% vì các đơn bị nhân đôi có tỉ lệ trễ gần như trung bình, tỉ lệ không lệch nhưng tổng thì lệch: 27,5 thành 28,18 tỷ đồng.
Bước 3: để pandas tự chặn lỗi.
try:
orders.merge(crm, on="customer_id", how="left", validate="many_to_one")
except MergeError as e:
print(type(e).__name__ + ":", str(e).splitlines()[0])
latest = crm.drop_duplicates("customer_id", keep="last") # giữ dòng xuất sau cùng
fixed = orders.merge(latest, on="customer_id", how="left", validate="many_to_one")
print("latest rows:", len(latest), " fixed join rows:", len(fixed))
print("order_id unique:", fixed["order_id"].is_unique)
Kết quả [Đã chạy] Python 3.12, pandas 3.1, NumPy 2.5, CPU x86_64, ngày 04/11/2026:
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
latest rows: 8000 fixed join rows: 50000
order_id unique: True
Với validate, lỗi xuất hiện ngay ở dòng merge, trước khi con số sai đi vào báo cáo. Đoạn except chỉ in dòng đầu thông báo; khi không bắt, pandas 3.1 còn liệt kê luôn các customer_id bị trùng. Cách sửa là một quyết định nghiệp vụ, không phải kỹ thuật: ở đây ta chọn giữ dòng xuất sau cùng (keep="last"), vì đó là địa chỉ mới của khách. Sau khi bỏ trùng, phép nối cho đúng 50.000 dòng và order_id duy nhất.
pandas 3 khác các hướng dẫn cũ ở đâu
Kho lab dùng pandas 3.1. Nhiều hướng dẫn trên mạng viết cho pandas 1.x và 2.x, và hai thay đổi sau làm code cũ chạy khác đi mà không báo lỗi.
Thứ nhất, Copy-on-Write (sao chép khi ghi) là hành vi mặc định: mọi phép lọc, chọn cột trả về một bảng mới về mặt logic, và sửa bảng mới không bao giờ sửa bảng gốc. Hệ quả là kiểu gán qua hai lần ngoặc vuông orders["col"][mask] = value (chained assignment, gán theo chuỗi) không còn tác dụng: orders["col"] giờ là một Series mới về mặt logic, phép gán sửa Series đó rồi vứt đi. Ở pandas 1.x và 2.x, đúng cách viết này lại sửa được bảng gốc, nên rất nhiều code cũ dựa vào nó. Cách đúng là một lần .loc[mask, "col"] = value.
Thứ hai, cột chữ có kiểu str thay vì object: chỉ chứa chuỗi hoặc giá trị thiếu, nên .str.strip() và các phép chuỗi khác an toàn hơn.
Bước 4: kiểm chứng trên dữ liệu thật.
orders = pd.read_parquet(DATA)
print("pandas", pd.__version__, "| district dtype:", orders["district"].dtype)
orders["promised_minutes_new"] = orders["promised_minutes"]
rainy = orders["is_rain"]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
orders["promised_minutes_new"][rainy] = 150 # cách cũ: chọn cột rồi gán theo mặt nạ
print("chained: changed rows =", int((orders["promised_minutes_new"] == 150).sum()))
print("warning:", caught[0].category.__name__ if caught else "none")
orders.loc[rainy, "promised_minutes_new"] = 150 # một lần .loc[hàng, cột]
print("loc: changed rows =", int((orders["promised_minutes_new"] == 150).sum()))
subset = orders[orders["district"] == "Dau Pho"]
subset["note"] = "checked" # sửa bản con, bảng gốc không đổi
print("original notes set to 'checked':", int((orders["note"] == "checked").sum()))
Kết quả [Đã chạy] Python 3.12, pandas 3.1, NumPy 2.5, CPU x86_64, ngày 04/11/2026:
pandas 3.1.0 | district dtype: str
chained: changed rows = 0
warning: ChainedAssignmentError
loc: changed rows = 12450
original notes set to 'checked': 0
Gán theo chuỗi sửa 0 dòng, kèm cảnh báo ChainedAssignmentError; cảnh báo không làm chương trình dừng nên trong script chạy đêm rất dễ bị bỏ qua. .loc sửa đúng 12.450 đơn ngày mưa. Dòng cuối cho thấy sửa subset không chạm tới orders.
Áp dụng vào Giao Hàng Anh Tí
Với ba bảng kết quả trên, Bình đổi kế hoạch: thay vì chia đều 16 shipper, ưu tiên Khu Cong Nghiep và Doi Thong (25,6% và 22,7%), đặc biệt vào ngày mưa khi tỉ lệ trễ ở Khu Cong Nghiep lên 34,7%. Lan ở nhóm dữ liệu lưu ý thêm: đơn express ngày mưa trễ 51%, nên lời hứa 60 phút cho express cần xem lại khi trời mưa.
Minh sửa quy trình báo cáo: mọi merge đều có validate, theo sau là assert len(joined) == len(orders) và assert joined["order_id"].is_unique, đúng loại kiểm tra bạn sẽ viết thành test ở bài pytest cuối chương.
Đọc thêm (không bắt buộc): chương Python cho dữ liệu của khóa Data Engineering trên GDev đi sâu vào đọc file lớn theo khối, làm sạch dữ liệu bẩn bằng pandas và kiểm thử pipeline.
Hiểu lầm thường gặp
Hiểu lầm 1: "Tỉ lệ khớp thì phép nối đúng." Sai, như báo cáo của Minh: 1.214 dòng thừa không đổi tỉ lệ trễ (18,57%) nhưng làm tổng giá trị thừa 0,68 tỷ đồng. Hãy kiểm tra số dòng và tính duy nhất của khóa, không chỉ kiểm tra một chỉ số.
Hiểu lầm 2: "df["col"][mask] = x sửa bảng df." Trong pandas 3 nó sửa 0 dòng. Cách viết này từng sửa được bảng gốc ở các bản trước, nên vẫn xuất hiện trong nhiều bài hướng dẫn và câu trả lời trên mạng. Còn df[mask]["col"] = x thì chưa bao giờ sửa được bảng gốc, kể cả ở bản cũ.
Hiểu lầm 3: "orders.items và orders["items"] là một." Cú pháp dấu chấm chỉ trỏ tới cột khi tên cột không trùng thuộc tính hay phương thức có sẵn. items, count, size, index đều trùng. Ngoặc vuông luôn đúng.
| Triệu chứng | Nguyên nhân | Cách kiểm chứng | Cách sửa |
|---|---|---|---|
| Tổng sau khi nối lớn hơn số kế toán | Khóa ở bảng bên phải bị trùng | crm["customer_id"].is_unique, so len trước và sau nối | validate="many_to_one", bỏ trùng theo quy tắc nghiệp vụ |
Gán giá trị xong nhưng bảng không đổi, có ChainedAssignmentError | Gán qua hai lần ngoặc vuông | Đếm số dòng mang giá trị mới | .loc[mask, "col"] = value |
'method' object has no attribute ... | Dùng df.items thay vì df["items"] | type(df.items) | Luôn dùng ngoặc vuông cho cột |
Tự kiểm tra (có đáp án)
Câu 1 (giải thích). Vì sao orders["is_late"].mean() cho ra tỉ lệ đơn trễ, còn sau groupby("district") thì cho ra tỉ lệ của từng quận?
Đáp án: True được tính là 1 và False là 0, nên trung bình bằng số đơn trễ chia tổng số đơn. Sau groupby, phép mean được áp dụng riêng trong từng nhóm (pha apply), rồi 8 kết quả được ghép thành bảng có chỉ mục là tên quận (pha combine).
Câu 2 (dự đoán). Bảng khách có 8.000 dòng, mỗi khách một dòng. Bạn thêm 50 dòng trùng cho 50 khách có tổng cộng 300 đơn rồi nối không có validate. Bảng kết quả có bao nhiêu dòng, và order_id còn duy nhất không?
Đáp án: 50.300 dòng, vì mỗi đơn của 50 khách đó được ghép với hai dòng khách. order_id không còn duy nhất. Nếu có validate="many_to_one", phép nối dừng bằng MergeError.
Câu 3 (vận dụng). Hà muốn đánh dấu priority = "high" cho mọi đơn ở Khu Cong Nghiep trong ngày mưa. Viết một dòng code đúng với pandas 3.
Đáp án: orders.loc[(orders["district"] == "Khu Cong Nghiep") & orders["is_rain"], "priority"] = "high". Một lần .loc sửa trực tiếp bảng gốc.
Tự tay làm
Data card ghi rằng xe đạp (bike) cộng thêm 10 phút so với xe máy. Trước khi chạy, ghi ra dự đoán: loại xe nào có tỉ lệ giao trễ cao nhất, loại nào thấp nhất?
Sau đó sửa pandas_filter_groupby.py: nhóm theo vehicle, tính n, late_rate, avg_minutes như bảng theo quận, rồi thêm một phép nhóm nữa tính quãng đường trung bình (distance_km) theo vehicle.
Kết quả mong đợi để tự kiểm: van có 1.380 đơn, trễ 28,4%; motorbike 43.168 đơn, trễ 20,1%; bike 5.452 đơn, chỉ trễ 3,7% và trung bình 53,9 phút. Quãng đường trung bình của bike là 1,59 km so với 5,77 km của motorbike. Viết một câu giải thích vì sao loại xe "chậm hơn 10 phút" lại ít trễ nhất. Hiện tượng một cột tương quan với kết quả vì nó đi kèm một cột khác sẽ gặp lại ở các chương về mô hình.
Điều cần nhớ
- DataFrame là bảng có chỉ mục, mỗi cột là một mảng có tên. Chọn cột bằng ngoặc vuông, lọc bằng mặt nạ boolean, sửa bằng
.loc[dòng, cột]. groupbylà split, apply, combine: một số trung bình chung (18,6%) có thể che khoảng cách 13,7% tới 25,6% giữa các quận.- Mọi
mergecầnvalidatevà kiểm tra số dòng sau nối: khóa trùng làm sai tổng (27,5 thành 28,18 tỷ) mà tỉ lệ vẫn đúng. - pandas 3 dùng Copy-on-Write: gán qua hai lần ngoặc vuông không sửa bảng. Bài tiếp theo biến những bảng kết quả này thành biểu đồ, mỗi biểu đồ trả lời đúng một câu hỏi.
Nguồn tham khảo
- pandas, Group by: split-apply-combine: https://pandas.pydata.org/docs/user_guide/groupby.html
- pandas, Merge, join, concatenate and compare: https://pandas.pydata.org/docs/user_guide/merging.html
- pandas,
DataFrame.merge(tham sốvalidate): https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.merge.html - pandas, Copy-on-Write: https://pandas.pydata.org/docs/user_guide/copy_on_write.html
- pandas, What's new in 3.0.0 (kiểu
strmặc định, Copy-on-Write): https://pandas.pydata.org/docs/whatsnew/v3.0.0.html - pandas, Comparison with SQL: https://pandas.pydata.org/docs/getting_started/comparison/comparison_with_sql.html
- AI Engineering from Scratch (MIT), phase 00, Data Management: https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/phases/00-setup-and-tooling/09-data-management