はじめに
Python で Web スクレイピングや REST API クライアントを実装する際、「ベース URL(ドメイン)」と「相対パス」を結合して絶対 URL を組み立てる場面が頻繁にあります。
base = "https://example.com/"
path = "api/v1/users"
# これらを結合したいこのとき、文字列の足し算(+ 演算子)で結合してしまうと、スラッシュ(/)の重複や欠落によるバグが発生しやすくなります。さらに、外部入力を URL に組み込むケースでは、結合方法によって意図しない外部ホストへのアクセス(SSRF など)につながるリスクもあります。
Python 標準ライブラリの urllib.parse.urljoin は、RFC 3986 に準拠した URL 結合ロジックを提供しており、これらの問題に対処する選択肢になります。一方で、挙動には独特のクセがあり、把握せずに使うと別のバグを生む原因にもなります。
本記事では、urljoin の基本的な使い方、スラッシュの有無による挙動の違い、そしてセキュリティ上の注意点まで、実務で必要な知識を整理します。
urljoinを使った正しい URL 結合方法- 文字列結合(
+)と比較したメリット - ベース URL の末尾スラッシュ有無による挙動の違い(4 パターン)
urljoinのセキュリティ上の落とし穴と対処法requestsライブラリや他のパス結合関数との使い分け
urljoin の基本的な使い方
urljoin は Python の標準ライブラリ urllib.parse モジュールに含まれています。使い方はシンプルで、「ベースとなる URL」と「結合したい URL(相対パス)」を引数に渡すだけです。
参考: urllib.parse — Python 3 公式ドキュメント
“Construct a full (“absolute”) URL by combining a “base URL” (base) with another URL (url). Informally, this uses components of the base URL, in particular the addressing scheme, the network location and (part of) the path, to provide missing components in the relative URL.”
(ベース URL と別の URL を結合して、完全な絶対 URL を組み立てます。具体的には、ベース URL のスキーム、ネットワークロケーション、パスの一部を使い、相対 URL の不足コンポーネントを補完します)
https://docs.python.org/3/library/urllib.parse.html
基本構文
from urllib.parse import urljoin
urljoin(base, url)- 第 1 引数(
base): 基底となる URL(例:https://example.com/) - 第 2 引数(
url): 結合したい相対パスなど(例:next/page)
実装例
ブログのトップページ URL とカテゴリーのパスを結合する例です。
from urllib.parse import urljoin
# ベースとなる URL
base_url = 'https://friendsnow.hatenablog.com/'
# 結合したい相対パス
relative_url = 'archive/category/プログラミング-Python'
# 結合を実行
full_url = urljoin(base_url, relative_url)
print(full_url)▼ 実行結果
https://friendsnow.hatenablog.com/archive/category/プログラミング-Pythonベース URL と相対パスが結合され、絶対 URL が生成されました。
なお、Python 3.5 以降の urljoin は RFC 3986(STD66) のセマンティクスに準拠した挙動になっています。それ以前のバージョン(3.4 以下)とは結果が異なる場合があるため、レガシー環境を扱う際は注意が必要です。
参考: urllib.parse.urljoin の仕様変更
“Changed in version 3.5: Behavior updated to match the semantics defined in RFC 3986.”
(バージョン 3.5 で変更: 挙動が RFC 3986 で定義されたセマンティクスに合わせて更新されました)
https://docs.python.org/3/library/urllib.parse.html
スラッシュ(/)の有無による挙動の違い
urljoin を使ううえで最も注意が必要なのが、「ベース URL の末尾」と「結合する相対 URL の先頭」のスラッシュ(/)の扱いです。
以下の 4 パターンを把握しておくと、想定外の結合結果を防ぎやすくなります。
パターン A: Base の末尾に「/」がある場合(通常)
ベース URL がディレクトリとして扱われ、最も直感的な結合になります。
base = "http://example.com/api/" # 末尾あり
path = "users"
print(urljoin(base, path))
# 結果: http://example.com/api/users
# → 末尾に追加されるパターン B: Base の末尾に「/」がない場合(置換される)
末尾にスラッシュがない場合、urljoin はベースの最後の部分(ここでは api)を「ファイル名」として扱います。そのため、最後のセグメントが取り除かれてから結合されます。
base = "http://example.com/api" # 末尾なし(ファイル扱い)
path = "users"
print(urljoin(base, path))
# 結果: http://example.com/users
# → 「api」が消えて「users」に置き換わるこれは、ブラウザで http://example.com/api というページを表示しているときに <a href="users"> というリンクをクリックした場合と同じ挙動です。同じ階層にある別のリソースへの遷移として解釈されます。
パターン C: 相対 URL が「/」で始まる場合(ルート相対パス)
結合する URL の先頭にスラッシュがある場合、ベース URL のパス部分は無視され、ドメイン直下(ルート)からのパスとして扱われます。
base = "http://example.com/api/v1/"
path = "/users" # 先頭あり(ルート指定)
print(urljoin(base, path))
# 結果: http://example.com/users
# → 「/api/v1/」は無視され、ドメイン直下に配置されるパターン D: 相対 URL が「//」で始まる場合(スキーマ相対 URL)
先頭が // で始まる URL は「スキーマ相対 URL」と呼ばれ、ベース URL のスキーム(http / https)だけを引き継ぎ、ホスト名(netloc)が置き換わります。
base = "https://example.com/api/v1/"
path = "//other.com/users"
print(urljoin(base, path))
# 結果: https://other.com/users
# → ホストが「other.com」に置き換わるこのパターンは、外部入力を相対 URL として扱うコードで意図しないホストへのリクエストを発生させる原因になりやすく、後述するセキュリティ上の注意点に直結します。
挙動まとめ(早見表)
| パターン | Base URL(第 1 引数) | Relative URL(第 2 引数) | 結合結果 | 解説 |
|---|---|---|---|---|
| A(通常) | …/folder/ | file | …/folder/file | ディレクトリ配下に追加 |
| B(置換) | …/folder | file | …/file | folder が file に置換される |
| C(ルート) | …/folder/ | /file | …/file | ドメイン直下に配置 |
| D(スキーマ相対) | https://a.com/x/ | //b.com/y | https://b.com/y | ホストが置き換わる |
開発時はベース URL の末尾スラッシュの有無を意識することをおすすめします。ディレクトリ配下にパスを追加したい場合は、ベース URL の末尾に必ず / を付けておくと、想定外の置換を避けられます。
文字列結合(+)との比較
ここまで読んで、「仕様が複雑なので自前で + 演算子で結合したほうが簡単では」と感じる方もいるでしょう。しかし、文字列結合で安全な URL を組み立てようとすると、コードは想定以上に複雑になります。
文字列結合(+)で書いた場合
スラッシュの重複や欠落を自前で制御する必要があります。
base = "http://example.com/api"
path = "users"
# そのまま足すと…
# http://example.com/apiusers (スラッシュ抜け)
# 自力で制御しようとすると…
if not base.endswith("/") and not path.startswith("/"):
full_url = base + "/" + path
elif base.endswith("/") and path.startswith("/"):
full_url = base[:-1] + path
else:
full_url = base + pathこのような条件分岐を URL 結合のたびに書くのは、可読性・保守性の観点でも望ましくありません。
urljoin を使った場合
urljoin は RFC 3986 に基づいて、これらの処理を内部で自動的に行います。
from urllib.parse import urljoin
# これだけで OK
full_url = urljoin(base, path)スラッシュの有無を気にせず、標準ライブラリに処理を委ねられる点が最大のメリットです。ただし、前述の「パターン B(末尾スラッシュなしによる置換)」の挙動だけは把握したうえで使う必要があります。
【重要】urljoin のセキュリティ上の注意点
urljoin には、Python 公式ドキュメントでも明示されているセキュリティ上の落とし穴があります。第 2 引数に絶対 URL が渡されると、ベース URL が完全に無視されるという挙動です。
参考: urllib.parse — Python 3 公式ドキュメント
“Because an absolute URL may be passed as the url parameter, it is generally not secure to use urljoin with an attacker-controlled url. For example in, urljoin(“https://website.com/users/”, username), if username can contain an absolute URL, the result of urljoin will be the absolute URL.”
(第 2 引数には絶対 URL が渡される可能性があるため、攻撃者が制御可能な URL に対して urljoin を使うことは一般的に安全ではありません。たとえば urljoin(“https://website.com/users/”, username) において、username に絶対 URL が含まれていた場合、urljoin の結果はその絶対 URL になります)
https://docs.python.org/3/library/urllib.parse.html
危険な挙動の実例
ユーザー入力を相対パスとして扱うつもりで urljoin に渡したケースを見てみます。
from urllib.parse import urljoin
base = "https://my-service.com/users/"
# 通常の入力(想定どおり)
user_input = "alice"
print(urljoin(base, user_input))
# 結果: https://my-service.com/users/alice
# 攻撃者が絶対 URL を入力した場合
user_input = "https://attacker.example/steal"
print(urljoin(base, user_input))
# 結果: https://attacker.example/steal
# → ベース URL が完全に無視される第 2 引数に絶対 URL を渡すと、ベース URL の情報がすべて破棄され、結果は第 2 引数そのものになります。この挙動は SSRF(Server-Side Request Forgery) などの脆弱性につながる可能性があります。
対処の考え方
外部入力を urljoin の第 2 引数に渡す処理を実装する場合は、以下のような対処を組み合わせることをおすすめします。
- 入力値の事前検証: 絶対 URL(
http://や//で始まる文字列)を弾く、もしくはエスケープする - 結合後の URL 検証:
urlparseで結合結果のnetlocを解析し、想定したホストと一致するかチェックする - 許可リスト方式: 許可されたホスト名のリストと照合する
以下は結合後にホスト名を検証する例です。
from urllib.parse import urljoin, urlparse
ALLOWED_HOST = "my-service.com"
def safe_join(base: str, user_input: str) -> str:
joined = urljoin(base, user_input)
parsed = urlparse(joined)
if parsed.netloc != ALLOWED_HOST:
raise ValueError(f"許可されていないホストです: {parsed.netloc}")
return joined
# 通常入力 → 正常に結合される
print(safe_join("https://my-service.com/users/", "alice"))
# 悪意ある入力 → ValueError が発生
print(safe_join("https://my-service.com/users/", "https://attacker.example/steal"))ユーザー入力をそのまま urljoin に渡す実装は避け、ホスト名の検証を組み合わせて利用することをおすすめします。
urljoin のその他の落とし穴
セキュリティ以外にも、urljoin には把握しておきたい挙動がいくつかあります。
クエリ文字列とフラグメントの扱い
第 2 引数にクエリ文字列(?…)やフラグメント(#…)が含まれる場合、ベース URL のクエリ・フラグメントは破棄されます。
from urllib.parse import urljoin
base = "https://example.com/page?lang=ja#top"
# 相対パスのみ
print(urljoin(base, "next"))
# 結果: https://example.com/next
# → ベース側のクエリ「?lang=ja」とフラグメント「#top」は破棄される
# 相対パス + クエリ
print(urljoin(base, "next?page=2"))
# 結果: https://example.com/next?page=2クエリ文字列を残したまま結合したい場合は、urlencode などで自前で構築するか、furl や yarl などの外部ライブラリを検討する余地があります。
os.path.join との混同に注意
URL 結合と似たような場面で、os.path.join や posixpath.join を使ってしまうケースがあります。これらはファイルシステムのパス結合用の関数であり、URL 結合には適していません。
import os
from urllib.parse import urljoin
# os.path.join は OS 依存(Windows ではバックスラッシュになる場合あり)
print(os.path.join("https://example.com/api/", "users"))
# Linux/macOS: https://example.com/api/users
# Windows: https://example.com/api\users ← 不正な URL
# urljoin は OS 非依存で常に正しく結合される
print(urljoin("https://example.com/api/", "users"))
# 結果: https://example.com/api/usersURL 結合には urljoin、ファイルパス結合には pathlib または os.path.join と、用途で使い分けることをおすすめします。
用途別の使い分け早見表
URL や文字列を結合する手段は urljoin 以外にもいくつかあります。用途に応じた使い分けを以下にまとめます。
| 手段 | 主な用途 | 特徴 | 向いているケース |
|---|---|---|---|
urljoin | URL 結合 | RFC 3986 準拠。スラッシュ処理を自動化 | ベース URL +相対パスを結合する場面全般 |
f-string / + | 文字列結合 | 完全に手動制御 | 結合パターンが固定で、入力が信頼できる場合のみ |
os.path.join | ファイルパス結合 | OS 依存(Windows でバックスラッシュ) | URL には非推奨、ローカルファイル操作向け |
posixpath.join | POSIX 形式パス結合 | 常にスラッシュ区切り | URL のパス部分のみ操作したい場合に限定的に有効 |
pathlib.PurePosixPath | パス操作(オブジェクト指向) | パスとしての操作が容易 | URL のパス分解・再構築 |
furl / yarl(外部) | 高機能な URL 操作 | クエリ・フラグメント編集が容易 | URL を頻繁に組み立て・編集する用途 |
実務では、シンプルな URL 結合は urljoin、複雑なクエリ操作を含む場合は yarl や furl という使い分けが扱いやすい構成です。
requests ライブラリとの併用例
Web スクレイピングや REST API クライアントでは、requests ライブラリと urljoin を組み合わせるケースが頻出します。requests 自体には URL 結合機能が組み込まれていないため、相対パスでのリクエスト送信には urljoin を併用するのが一般的です。
import requests
from urllib.parse import urljoin
API_BASE = "https://api.example.com/v1/"
def fetch_user(user_id: int) -> dict:
url = urljoin(API_BASE, f"users/{user_id}")
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
# 呼び出し例
user = fetch_user(123)
# → https://api.example.com/v1/users/123 にリクエスト送信複数のエンドポイントを扱う場合は、ベース URL を一元管理しておくことで、ドメイン変更やバージョン切り替え時の修正範囲を最小化できます。
なお、requests.Session を使った実装でも同様にベース URL の管理に urljoin が有効です。Session をベース URL 付きで使いたい場合は、requests-toolbelt の BaseUrlSession などのサードパーティ拡張も選択肢になります。
まとめ
本記事では、Python の urllib.parse.urljoin を安全に使うためのポイントを解説しました。
- URL の結合は
+ではなくurljoinを使うことで、RFC 3986 準拠の処理が任せられる - ベース URL の末尾にスラッシュがない場合、最後のパスセグメントが置換される
- スキーマ相対 URL(
//で始まる URL)はホスト名が置き換わる - 第 2 引数に絶対 URL が渡されるとベース URL が無視されるため、外部入力を扱う際は検証が必要
- クエリ・フラグメント付き URL の結合では、ベース側の情報が破棄される点に注意
os.path.joinと混同しない(URL 結合にはurljoin、ファイルパスにはpathlib)requestsと組み合わせると、API クライアント実装でベース URL を一元管理しやすい
以上、最後までお読みいただきありがとうございました。


