· By Wataru Ito · Coding · 14 min read
Learning Python and DuckDB with Japan Travel Data, Part 1: From Weather CSVs to Parquet
A project I actually wanted to build: downloading Japanese weather data, working through CSV encodings and quality flags, and preparing ten years of daily observations for 56 destinations.
AI has made learning to code feel a little strange to me.
I still need to understand the code well enough to ask useful questions and make decisions about how to build things. But when I can ask for Rust or Go and get working-looking code back, I sometimes feel less motivated to learn another language from scratch.
Then I realized something about the books and tutorials I kept putting down: I didn’t particularly care about what I was building, I mean, those ready made tutorial projects. With AI, I could work on something I was curious about with NEW language and tech stack, and ask questions as I went.
And when I got tired or stuck, I could ask to see a finished version. A bit like a cooking show: “And here’s one we baked earlier.” And ask even more how it works.
So I tried it. It was fun enough that I wanted to write up both the process and the results.
I wanted to get more comfortable with Python and DuckDB. I wasn’t starting from zero with Python or SQL, but I hadn’t built a practical project with them from start to finish. This seemed like a good way to get moving.
The subject was Japan travel. I wanted to compare destinations and months using past weather observations. How much does the choice of month change your chances of spending a few days outside without too much heat or rain?
This series follows the technical work. Part 1 covers downloading and preparing the data. Part 2 builds and evaluates four-day itineraries in SQL. Part 3 adds accommodation statistics, looks at scoring choices, and checks the results. I’m writing for readers who want to follow a public-data project through its Python and SQL code.
The project code, saved datasets, and analysis outputs are on GitHub, under bin/analytics/japan-travel-weather/.
Compare destinations by departure month
I used a destination and a departure month as the unit of comparison. Monthly average temperatures wouldn’t tell me what a four-day visit might have looked like, so I built consecutive four-day windows from the historical record and counted how often they met a set of temperature and rainfall conditions.
The weather data runs from January 1, 2016, through December 31, 2025. I ended up with 56 destinations, 3,653 days per destination, and 204,568 daily rows. The resulting percentages describe that historical period. They aren’t forecasts for a future trip.
For the nationwide analysis, I used daily maximum temperature and daily precipitation. I started with a short stretch of Tokyo (東京) data, expanded to ten years, added Sapporo (札幌) and Naha (那覇), and then worked through the larger set. I looked at other measurements during the early exercises, but kept these two for the nationwide version.
Give Python and DuckDB different jobs
I used Python to retrieve and read files and check their contents. I used DuckDB to query the prepared tables with SQL. For the analysis files, I chose Parquet, a format that stores data by column and preserves types such as dates and numbers. I kept the original CSVs separately.
I put the Python project inside the same repository as this blog. The examples use bin/analytics/japan-travel-weather/ as the working directory.
I used uv to manage the Python version and dependencies. With uv already installed, these commands set up a new project at that path. They show the initial setup; don’t rerun the initialization over an existing project.
Environment setup
mkdir -p bin/analytics/japan-travel-weather
cd bin/analytics/japan-travel-weather
uv init --bare
uv python pin 3.11.14
uv add duckdb
uv run python -c "import duckdb; print(duckdb.sql('SELECT 42 AS answer').fetchall())"The final command prints [(42,)]: a list containing one tuple, which represents the one row returned by SQL. Early on, I spent time working through outputs like this and the Python syntax around them.
uv records the dependencies and their versions in its configuration and lock files. Later, I added openpyxl for Excel and pypdf for PDFs. The examples describe the project as it stood on October 19, 2026.
The .py filenames below refer to scripts written for this project. They aren’t built-in Python or DuckDB commands. I include complete code for the short scripts and label excerpts from the larger ones. The paths match the project’s directory layout.
Read the JMA CSVs
I downloaded daily observations from the Japan Meteorological Agency’s (気象庁) historical weather data page. In the interface, I selected the station, date range, and measurements, along with the quality information and homogeneity numbers. I used a single date column and numeric values.
Some of the original CSVs use CP932, a Japanese character encoding. The later loader expects UTF-8, so I wrote convert_to_utf8.py. This is the complete script with the longer comments removed. Save it under that name and pass it the path to a CSV.
convert_to_utf8.py
import argparse
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description="Convert a CP932 CSV to UTF-8")
parser.add_argument("source", type=Path, help="Path to the CSV to convert")
args = parser.parse_args()
source = args.source
target = source.with_name(source.stem + "_utf8" + source.suffix)
try:
text = source.read_bytes().decode("cp932")
with target.open("xb") as output:
output.write(text.encode("utf-8"))
except (OSError, UnicodeError) as error:
parser.exit(1, f"Conversion failed: {error}\n")
print(f"Saved: {target}")
if __name__ == "__main__":
main()source holds the input path. The script reads the bytes, decodes them as CP932, and writes the text as UTF-8. It saves the new file in the same directory, adding _utf8 before the extension. Opening the output with xb creates a new file and fails if that file already exists.
For example, with the Tokyo (東京) source file at the following path, run this from the project directory.
Run convert_to_utf8.py
uv run python convert_to_utf8.py data/interim/jma_tokyo_daily_2025.csv
# Output: data/interim/jma_tokyo_daily_2025_utf8.csvThe CSV doesn’t start with observations on its first line. It has several header rows for the download timestamp, station name, measurement names, and supplementary fields. Precipitation can also have a “no phenomenon” information column. The JMA (気象庁) CSV documentation explains those fields and when they appear.
Before writing a loader, I wanted to see where the dates and values started. I wrote inspect_csv.py to print the first nine rows of the converted Tokyo (東京) file. This is the complete code with the comments trimmed.
inspect_csv.py
from pathlib import Path
import csv
csv_path = Path("data/interim/jma_tokyo_daily_2025_utf8.csv")
with csv_path.open(encoding="utf-8", newline="") as csv_file:
reader = csv.reader(csv_file)
for line_number, row in zip(range(1, 10), reader):
print(
f"line={line_number:>2}",
f"columns={len(row):>2}",
repr(row[:8]),
)columns shows the number of columns in a row; the last value shows the first eight fields. Change csv_path to inspect another file. In the first learning exercises, I specified the column positions. For the nationwide version, I switched to locating columns by their headers.
The post-download quality checker can read both CP932 and UTF-8. The conversion step is needed for the nationwide loader that comes afterward.
Keep quality flags separate from homogeneity numbers
A numeric value isn’t enough to make an observation usable. For both maximum temperature and precipitation, I required a quality flag of 8 and a non-NULL value. In the JMA (気象庁) data, 8 identifies a normal value.
Homogeneity numbers serve a different purpose. I kept them to help identify changes in observation conditions. Periods with different homogeneity numbers may not be suitable for a straightforward comparison. The numbers also aren’t quality scores you can compare across stations. I needed to read the definitions of both fields separately.
I retained dates even when measurements were missing or had poor quality flags. Later, I would build four-day windows. If I deleted rows first, four consecutive rows could span more than four calendar days. I waited until after constructing each itinerary to decide whether its observations were usable.
Choose 56 destinations from 60 candidates
I kept a CSV mapping each destination to a weather station. The names don’t always match: I used Saitama (さいたま) observations for Kawagoe (川越), Tsujido (辻堂) for Kamakura (鎌倉), and Oita (大分) for Beppu (別府).
I started with 60 candidates. I left Nikko (日光) out of the download set because the observation site changed during the study period. On November 16, 2022, the Imaichi (今市) station moved and became Nikko-Higashimachi (日光東町). JMA (気象庁) states that it discontinued the old statistical series at the move, so the observations before and after it should not be treated as one continuous series. See the JMA (気象庁) relocation notice.
Joining the two records could mix changes in weather from year to year with differences caused by the observation site. I wanted ten years of observations for the same location, so I didn’t combine Imaichi (今市) and Nikko-Higashimachi (日光東町) into one destination record.
I also left Hakone (箱根) and the Kumano Hongu area (熊野本宮周辺) out of the download set because I hadn’t settled on representative stations for them. I downloaded the remaining 57 station records, then held Nara (奈良) out of the main analysis because its maximum-temperature data had more than one homogeneity number.
That doesn’t establish that Nara’s (奈良) data is unusable. It means I kept 57 downloaded station records and used 56 destinations in this analysis. For destinations represented by a nearby station, I also wouldn’t assume that one station captures the weather across the whole visitor area.
The table lists all 60 candidates. “Included” means included in the main analysis.
| Destination | Prefecture | Included in the analysis? |
|---|---|---|
| Sapporo (札幌) | Hokkaido (北海道) | Included |
| Hakodate (函館) | Hokkaido (北海道) | Included |
| Otaru (小樽) | Hokkaido (北海道) | Included |
| Furano (富良野) | Hokkaido (北海道) | Included |
| Kushiro (釧路) | Hokkaido (北海道) | Included |
| Shiretoko (Utoro) (知床・ウトロ) | Hokkaido (北海道) | Included |
| Aomori (青森) | Aomori (青森県) | Included |
| Hirosaki (弘前) | Aomori (青森県) | Included |
| Morioka (盛岡) | Iwate (岩手県) | Included |
| Sendai (仙台) | Miyagi (宮城県) | Included |
| Yamagata (山形) | Yamagata (山形県) | Included |
| Aizu-Wakamatsu (会津若松) | Fukushima (福島県) | Included |
| Tokyo (東京) | Tokyo (東京都) | Included |
| Kawagoe (川越) | Saitama (埼玉県) | Included |
| Yokohama (横浜) | Kanagawa (神奈川県) | Included |
| Kamakura (鎌倉) | Kanagawa (神奈川県) | Included |
| Nikko (日光) | Tochigi (栃木県) | Excluded from download set |
| Hakone (箱根) | Kanagawa (神奈川県) | Excluded from download set |
| Kusatsu Onsen (草津温泉) | Gunma (群馬県) | Included |
| Chichibu (秩父) | Saitama (埼玉県) | Included |
| Narita (成田) | Chiba (千葉県) | Included |
| Lake Kawaguchi (河口湖) | Yamanashi (山梨県) | Included |
| Karuizawa (軽井沢) | Nagano (長野県) | Included |
| Nagano (長野) | Nagano (長野県) | Included |
| Matsumoto (松本) | Nagano (長野県) | Included |
| Hakuba (白馬) | Nagano (長野県) | Included |
| Takayama (高山) | Gifu (岐阜県) | Included |
| Shirakawa-go (白川郷) | Gifu (岐阜県) | Included |
| Kanazawa (金沢) | Ishikawa (石川県) | Included |
| Nagoya (名古屋) | Aichi (愛知県) | Included |
| Atami (熱海) | Shizuoka (静岡県) | Included |
| Shimoda (下田) | Shizuoka (静岡県) | Included |
| Kyoto (京都) | Kyoto (京都府) | Included |
| Osaka (大阪) | Osaka (大阪府) | Included |
| Nara (奈良) | Nara (奈良県) | On hold |
| Kobe (神戸) | Hyogo (兵庫県) | Included |
| Himeji (姫路) | Hyogo (兵庫県) | Included |
| Koyasan (高野山) | Wakayama (和歌山県) | Included |
| Ise (伊勢) | Mie (三重県) | Included |
| Kinosaki Onsen (城崎温泉) | Hyogo (兵庫県) | Included |
| Kumano Hongu area (熊野本宮周辺) | Wakayama (和歌山県) | Excluded from download set |
| Hiroshima (広島) | Hiroshima (広島県) | Included |
| Miyajima (宮島) | Hiroshima (広島県) | Included |
| Kurashiki (倉敷) | Okayama (岡山県) | Included |
| Matsue (松江) | Shimane (島根県) | Included |
| Izumo (出雲) | Shimane (島根県) | Included |
| Matsuyama (松山) | Ehime (愛媛県) | Included |
| Takamatsu (高松) | Kagawa (香川県) | Included |
| Kochi (高知) | Kochi (高知県) | Included |
| Fukuoka (福岡) | Fukuoka (福岡県) | Included |
| Nagasaki (長崎) | Nagasaki (長崎県) | Included |
| Kumamoto (熊本) | Kumamoto (熊本県) | Included |
| Aso (阿蘇) | Kumamoto (熊本県) | Included |
| Beppu (別府) | Oita (大分県) | Included |
| Yufuin (由布院) | Oita (大分県) | Included |
| Kagoshima (鹿児島) | Kagoshima (鹿児島県) | Included |
| Yakushima (屋久島) | Kagoshima (鹿児島県) | Included |
| Naha (那覇) | Okinawa (沖縄県) | Included |
| Ishigaki (石垣島) | Okinawa (沖縄県) | Included |
| Miyakojima (宮古島) | Okinawa (沖縄県) | Included |
Download the nationwide data
I downloaded daily observations for 57 JMA (気象庁) stations as CSV files.
| Item | Data |
|---|---|
| Coverage | 57 stations, including Nara (奈良); 56 destinations in the main analysis |
| Period | January 1, 2016–December 31, 2025 |
| Time interval | Daily, with 3,653 dates per station |
| Measurements | Daily maximum temperature and daily precipitation |
| Supplementary fields | Quality flags and homogeneity numbers; precipitation may also have a “no phenomenon” column |
| Format | CSV |
| Original file directory | data/raw/jma_daily_57_auto/ |
I saved one source file per station. I kept missing and poor-quality values in those files and decided whether to use them later. Alongside each CSV, I saved the request conditions, retrieval timestamp, station information, and a SHA-256 hash for checking the file’s contents.
After downloading, I checked the station name, required columns, and coverage of all 3,653 dates. Having a row for each date doesn’t mean that every measurement is usable. The loader and the itinerary checks in Part 2 handle that distinction.
To check the saved CSVs again, run this from the project directory.
Run check_weather_coverage.py
uv run python check_weather_coverage.py \
--input-dir data/raw/jma_daily_57_auto \
--output data/processed/coverage_daily_check_auto.csvMatch columns by name and save to Parquet
I prepared UTF-8 CSVs for the 56 selected destinations in data/interim/56_region_to_analyze/. The loader doesn’t create that directory’s contents from the 57-station source folder. Selecting the files, leaving out Nara (奈良), and converting the encoding are separate preparation steps. The existing project has those inputs ready.
In load_weather_56.py, I identify columns using the measurement name and the supplementary field name together. This excerpt defines the mapping. The last two values in each tuple are the output column name and its data type.
load_weather_56.py
FIELDS = [
("最高気温(℃)", "", "temp_max_c", "DOUBLE"),
("最高気温(℃)", "品質情報", "temp_max_quality", "INTEGER"),
("最高気温(℃)", "均質番号", "temp_max_homogeneity", "INTEGER"),
("降水量の合計(mm)", "", "precipitation_mm", "DOUBLE"),
("降水量の合計(mm)", "現象なし情報", "precipitation_no_event", "INTEGER"),
("降水量の合計(mm)", "品質情報", "precipitation_quality", "INTEGER"),
("降水量の合計(mm)", "均質番号", "precipitation_homogeneity", "INTEGER"),
]The Japanese strings here are the actual CSV headers, so they need to stay in Japanese. For example, the loader finds the column labeled 降水量の合計(mm) plus 品質情報 and stores it as precipitation_quality. Those labels mean total precipitation in millimeters and quality information. If the optional 現象なし情報 column is absent, the loader writes NULL. A missing required column stops the import.
The script also checks the input file list for missing or unexpected files. Each output row includes the destination ID, prefecture, station number, date, and source filename. I store station numbers as strings because they are identifiers, dates as DATE, temperatures and precipitation as DOUBLE, and quality flags as INTEGER.
Run load_weather_56.py
uv run python load_weather_56.pyThe output is data/processed/jma_56_regions_daily_2016_2025.parquet. Before saving, I check for duplicate destination/date pairs, the date range, and row counts. I then write a temporary Parquet file, read it back, and compare it with the original table in both directions.
load_weather_56.py: check the saved data
(SELECT * FROM weather_daily EXCEPT ALL SELECT * FROM saved)
UNION ALL
(SELECT * FROM saved EXCEPT ALL SELECT * FROM weather_daily)
LIMIT 1This query runs after the loader has created weather_daily and saved. A one-way comparison could miss added or lost rows, depending on which direction I checked. If both comparisons return no differences, the loader moves the temporary file to the final output path.
The data is ready for itinerary checks
At this point, I had 204,568 daily rows for 56 destinations, with explicit types for dates and measurements. Each row retained the quality flags, homogeneity numbers, and source CSV name. I had checked the data’s structure; I hadn’t yet decided whether a given stretch of weather met the travel conditions.
In Part 2, I use this Parquet file to build four-day windows starting on each date. I separate the percentage of itineraries I can evaluate from the percentage that meet the weather conditions.