I still remember the first time a ‘works on my machine‘ bug traced back to a file path. On macOS, my script happily wrote logs to ./logs/app.log. On a Windows CI runner, the exact same code produced a path that looked plausible, but the file never appeared—because I‘d glued strings together with "/" like it was 2008. That incident taught me a lesson I‘ve kept repeating in code reviews: paths are not strings, even if they‘re represented as strings.
When you build paths by concatenation, you accidentally encode assumptions about separators, absolute-path behavior, trailing slashes, and drive letters. os.path.join() exists to stop you from doing that. It‘s simple, but it has rules that matter a lot once your code ships to multiple operating systems, runs inside containers, or handles user-provided path fragments.
I‘m going to show you how os.path.join() really behaves (including the ‘reset‘ behavior with absolute components), what edge cases I watch for in production code, and how I decide between os.path.join() and pathlib in modern Python projects.
What os.path.join() actually guarantees
The job of os.path.join() is to combine path components into a single path string in a way that matches the rules of the current platform.
Key guarantees you can count on:
- Correct separator for the platform:
/on Unix-like systems and\on Windows (more precisely: whateveros.sepand platform path semantics require). - A clean concatenation boundary: it inserts a separator between components when needed.
- A predictable ‘absolute path resets earlier parts‘ rule: if a later component is absolute, it discards what came before and starts from that absolute component.
What it does not guarantee:
- It does not normalize
..or.segments. - It does not check whether the path exists.
- It does not make the path absolute.
- It does not protect you from path traversal when inputs come from outside your trust boundary.
That distinction—‘join‘ vs ‘normalize‘ vs ‘validate‘—is where most bugs happen.
A practical mental model: left-to-right, with reset rules
Here‘s the model I keep in my head:
- Start with the first component.
- For each next component:
– If it‘s empty, it usually contributes nothing.
– If it‘s an absolute path (or otherwise treated as a root/drive reset on Windows), drop what you built so far and restart from this component.
– Otherwise, append it with exactly one separator boundary.
This example demonstrates the reset rule clearly:
import os
path = os.path.join("/home", "user", "documents", "/etc", "config.txt")
print(path)
On Unix-like systems, the output is:
/etc/config.txt
That surprises people until they realize what happened: "/etc" is absolute, so it discards "/home/user/documents".
I use this behavior intentionally in some cases (for example, when joining user overrides onto defaults), but I never ‘accidentally rely‘ on it. When an absolute component might appear unexpectedly, I guard against it.
Concrete examples that match real code
import os
print(os.path.join("/home", "User/Desktop", "report.csv"))
print(os.path.join("User/Documents", "/home", "report.csv"))
print(os.path.join("/User", "Downloads", "report.csv", "/home"))
On Unix-like systems this prints:
/home/User/Desktop/report.csv
/home/report.csv
/home
What I want you to notice:
- The middle call resets at
"/home". - The last call resets at the final
"/home", so everything before it disappears.
If you‘re accepting path fragments from a config file or CLI flag, this rule is why you should validate inputs before joining.
Syntax details that matter in real projects
The signature is:
os.path.join(path, *paths)
A few practical notes:
*pathsmeans you can pass a variable number of components.- You can join from lists/tuples using unpacking:
import os
parts = ["var", "log", "myapp", "server.log"]
full_path = os.path.join("/", *parts)
print(full_path)
- A ‘path-like object‘ can be a string, bytes, or an object implementing
fspath().
Strings vs bytes (don‘t mix them)
os.path functions generally expect you to use either all str or all bytes. Mixing them raises an exception.
import os
base_dir = b"/var/log"
# This will raise TypeError because "app.log" is str, not bytes.
# fullpath = os.path.join(basedir, "app.log")
fullpath = os.path.join(basedir, b"app.log")
print(full_path)
In 2026, I strongly prefer str paths unless I‘m interfacing with a low-level API that truly needs bytes.
Empty strings and trailing separators
A subtle behavior that sometimes matters: joining with an empty last component can preserve a trailing separator, depending on platform rules.
import os
print(os.path.join("/var", "log", ""))
I don‘t rely on this. If I need a directory path, I keep the semantics explicit: I store directory paths as directories and file paths as files, and I only add trailing separators for display, not for logic.
Cross-platform edge cases (especially on Windows)
Most os.path.join() explanations stop at ‘it uses / or \.‘ The bigger traps are Windows drive letters, UNC paths, and ‘absolute-ish‘ paths.
Drive letters can reset earlier components
On Windows, a component like "C:\\temp" is absolute and will reset earlier parts.
import os
print(os.path.join("C:\\Projects", "myapp", "data", "records.json"))
print(os.path.join("C:\\Projects", "myapp", "D:\\Exports", "records.json"))
The second join discards "C:\\Projects\\myapp" once it hits "D:\\Exports".
Drive-relative paths are a separate gotcha: C:folder vs C:\\folder
This is one of those Windows rules that surprises even experienced developers:
C:\\folderis an absolute path on drive C.C:folderis relative to the current working directory on drive C (yes, Windows tracks a per-drive working directory concept).
That means some strings that look ‘drive-ish‘ are not truly absolute, and os.path.join() follows Windows semantics.
If you ever see someone accept a user-supplied fragment that starts with something like "C:tmp", treat it as a red flag. It‘s not inherently malicious, but it‘s a sign you might be mixing ‘filename‘ and ‘path‘ concepts.
Rooted paths keep the drive: \\Windows style
On Windows, a component starting with a backslash (like "\\Windows") is rooted to the current drive. When joined onto a drive path, it typically keeps the drive but resets the rest.
Conceptually:
os.path.join("C:\\Projects", "\\Windows")becomesC:\\Windows.
I bring this up because it‘s easy to think ‘it doesn‘t have C: so it‘s relative.‘ It‘s not.
UNC paths are their own root
UNC paths look like \\\\server\\share\\folder. If a UNC component appears, it becomes the new root.
import os
unc_root = "\\\\fileserver\\team-share"
print(os.path.join(unc_root, "builds", "2026-02-04", "artifact.zip"))
If you‘re building paths for Windows fleet machines, I recommend writing at least one unit test that runs on Windows (or in a Windows CI job) specifically for drive/UNC behavior. It‘s not optional if your code touches the filesystem.
Mixed separators don‘t get ‘fixed‘ automatically
os.path.join() won‘t magically convert every embedded / into \ on Windows if you pass in strings that already contain separators. It will join components sensibly, but it won‘t rewrite arbitrary strings into the platform‘s canonical style.
If you need normalization, you want os.path.normpath() (or Path(...).resolve() / Path(...).absolute() in pathlib, depending on what you‘re doing).
Joining paths is not normalization (and not validation)
This is the single most important rule I teach juniors: join() is not the same as ‘make it safe and correct.‘
join() vs normpath() vs abspath() vs realpath()
Here‘s how I separate responsibilities:
os.path.join(): combine components with platform rules.os.path.normpath(): collapse redundant separators and./..purely as a string operation.os.path.abspath(): make a path absolute by prepending the current working directory, then normalize.os.path.realpath(): resolve symlinks (where supported), giving you the ‘real‘ filesystem location.
A quick demo:
import os
raw = os.path.join("/srv/apps", "myapp", "..", "shared", "config", "settings.toml")
print("raw:", raw)
print("norm:", os.path.normpath(raw))
print("abs :", os.path.abspath(raw))
I‘ll often do join() first, then normpath() when I‘m assembling internal paths that might contain .. segments.
Security note: join() does not stop traversal
If you do this with untrusted input:
import os
def unsafeuserfile(basedir: str, userinput: str) -> str:
return os.path.join(basedir, userinput)
…and user_input is "../../etc/passwd", you‘ve just created a traversal path string. Nothing about join() prevents it.
A safer pattern is:
- Normalize and then enforce containment inside the base directory.
- Prefer
pathlibfor the containment check, because it‘s harder to get wrong.
from pathlib import Path
def safeuserfile(basedir: str, userinput: str) -> Path:
base = Path(base_dir).resolve()
candidate = (base / user_input).resolve()
# Enforce that the resolved candidate stays within base.
# This blocks "../" escapes and also catches symlink tricks.
if base not in candidate.parents and candidate != base:
raise ValueError("Invalid path")
return candidate
If you‘re handling uploads, report exports, or anything exposed to the internet, this is the difference between ‘fine in dev‘ and ‘incident in prod.‘
Real-world patterns I use os.path.join() for
I still reach for os.path.join() constantly, especially in codebases that already lean on os and os.path. Below are patterns that show up in my day-to-day work.
1) Reading and writing a file by joining base directory and filename
import os
base_dir = "/home/user"
filename = "example.txt"
fullpath = os.path.join(basedir, filename)
with open(full_path, "r", encoding="utf-8") as f:
print(f.read())
Why I like this pattern:
- The ‘where‘ (
base_dir) and ‘what‘ (filename) stay separate until the final moment. - It‘s trivial to swap
base_dirwhen you move from local dev to containers.
2) Listing files and producing absolute paths for processing
import os
current_dir = os.getcwd()
for name in os.listdir(current_dir):
fullpath = os.path.join(currentdir, name)
print("processing:", full_path)
I often pair this with filtering:
- Use
os.path.isfile(full_path)before opening. - Use
os.path.isdir(full_path)when recursing.
3) Building a predictable project layout
For scripts that run from various working directories, I avoid relying on os.getcwd() and instead anchor on the script location.
import os
here = os.path.dirname(os.path.abspath(file))
config_path = os.path.join(here, "config", "settings.toml")
print(config_path)
This is a classic pattern that still earns its keep.
4) Temporary directories and cache files
When I need a per-run scratch space, I typically use tempfile and then join inside it.
import os
import tempfile
with tempfile.TemporaryDirectory(prefix="myapp-") as tmp:
lock_path = os.path.join(tmp, "index.lock")
with open(lock_path, "w", encoding="utf-8") as f:
f.write("locked")
print("created:", lock_path)
This keeps your temp usage correct across platforms without you thinking about separators.
5) Configurable base directories with safe defaults
I like patterns where the base directory is configurable, but file names are controlled by the app.
import os
def applogpath(log_dir: str) -> str:
# I keep the filename fixed so only the directory is configurable.
return os.path.join(log_dir, "myapp.log")
print(applogpath("/var/log"))
If you allow users to override the whole path, you now have to validate a lot more.
Common mistakes I still see in 2026 (and how I correct them)
Even with better tooling, these issues show up in reviews all the time.
Mistake 1: Using string concatenation for paths
Bad:
logs = base_dir + "/" + "logs" + "/" + "app.log"
Better:
import os
logs = os.path.join(base_dir, "logs", "app.log")
If you‘re thinking ‘but it works on Linux,‘ you‘re proving the point: you‘ve encoded the OS assumption into your code.
Mistake 2: Expecting join() to remove .. segments
Bad assumption:
import os
p = os.path.join("/srv", "myapp", "..", "shared")
# p is "/srv/myapp/../shared" (still contains "..")
Correct approach:
import os
p = os.path.normpath(os.path.join("/srv", "myapp", "..", "shared"))
Mistake 3: Accidentally allowing absolute overrides
This one causes real bugs:
import os
def buildoutputpath(outputdir: str, filename: str) -> str:
return os.path.join(outputdir, filename)
print(buildoutputpath("/srv/exports", "/etc/passwd"))
# Result: "/etc/passwd" on Unix-like systems
If file_name must be a filename, I enforce that:
import os
def buildoutputpath(outputdir: str, filename: str) -> str:
filename = os.path.basename(filename)
return os.path.join(outputdir, filename)
That simple basename step prevents absolute resets and also strips directory components.
Mistake 4: Mixing pathlib.Path and os.path inconsistently
I‘m fine with mixing when there‘s a reason (for example, passing a Path into an API that accepts path-like objects). What I avoid is a codebase where half the functions return str paths and the other half return Path objects with no convention.
My rule:
- If a module is mostly
pathlib, returnPathconsistently. - If a module is mostly
os.path, returnstrconsistently. - Convert at the boundary.
That removes a whole category of ‘why is this failing on Windows?‘ confusion.
os.path.join() vs pathlib.Path: what I reach for today
In modern Python, pathlib is often the cleaner default. Still, os.path.join() remains a great tool, especially in smaller scripts and existing codebases.
Here‘s how I compare them.
Traditional approach
—
os.path.join(base, "data", "events.json")
Path(base) / "data" / "events.json" .. os.path.normpath(p)
p.resolve() (careful: hits filesystem for symlinks) os.path.abspath(p)
Path(p).resolve() or Path(p).absolute() os.walk(root)
Path(root).rglob("*.json") tricky with strings
resolve() + parents checks My recommendation is specific:
- For new application code: I typically start with
pathlib.Pathbecause it makes intent obvious and containment checks are easier to write correctly. - For scripts, quick tooling, and legacy modules:
os.path.join()is perfectly fine, especially when you‘re already usingos.listdir,os.walk, andos.environheavily.
One important practical note: you can pass Path objects into many standard library functions because they accept path-like objects. So switching doesn‘t require a big rewrite.
Performance and correctness notes (the boring stuff that saves you later)
os.path.join() is fast. In typical application code, it‘s not your bottleneck. Even in batch scripts, path joining is usually lost in the noise compared to disk I/O, network calls, parsing, or compression.
Where performance does show up is when you do path work inside tight loops over hundreds of thousands of items. In those cases, I‘ve seen path manipulation take ‘noticeable but not scary‘ time—think on the order of tens to hundreds of milliseconds per large batch, depending on how much extra processing you do (joins, normalizations, regexes, stats) and the Python version.
But correctness is the real win. A path bug can cost hours of debugging and break deployments in ways that look like permissions or missing files. I will happily spend a tiny slice of CPU time on os.path.join() if it buys me cross-platform sanity.
Micro-optimizations I actually use (rarely)
Most of the time, I don‘t.
When I do, it‘s usually one of these patterns:
- Hoist constant prefixes: join the base once, then append the varying part.
import os
root = os.path.join("/srv", "imports")
for name in filenames:
p = os.path.join(root, name)
process(p)
- Avoid repeated normalization: normalize once at the boundary, not inside every loop.
import os
base = os.path.normpath(basefromconfig)
for rel in items:
p = os.path.join(base, rel)
handle(p)
- Don‘t call
abspath()repeatedly: computecwd = os.getcwd()orhere = os.path.dirname(os.path.abspath(file))once, then reuse.
These are not about making the program faster; they‘re about not doing unnecessary work when you‘re already touching a ton of files.
How I think about ‘absolute‘ in practice
People say ‘absolute path‘ like it‘s a single thing. In real systems, it‘s more like a family of concepts.
Unix-like systems
On Unix-like systems, an absolute path starts with /. That‘s the root. It‘s simple, which is why Unix path logic feels more intuitive.
/var/log/app.logis absolute.var/log/app.logis relative.
Windows systems
Windows has more ways to be ‘rooted‘:
- Drive absolute:
C:\\Windows\\System32. - Rooted on current drive:
\\Windows\\System32. - UNC share:
\\\\server\\share\\folder\\file.txt. - Drive-relative:
C:temp\\file.txt(relative to the working directory on drive C).
If you only remember one Windows rule for os.path.join(), make it this: components can reset earlier parts in more than one way (drive change, rooted path, UNC), and some strings that look ‘drive-related‘ are not absolute.
Working with path-like objects (fspath) without drama
A very practical modern detail: many Python APIs accept path-like objects (including pathlib.Path) because they call os.fspath() internally.
That means all of these can be valid inputs to os.path.join():
strpathspathlib.Pathinstances- custom objects implementing
fspath()
Here‘s what I do in production code when I want to accept both str and Path cleanly:
import os
from pathlib import Path
from typing import Union
Pathish = Union[str, os.PathLike]
def buildcachepath(cache_dir: Pathish, name: str) -> str:
# Normalize to a real filesystem path string at the boundary.
base = os.fspath(cache_dir)
return os.path.join(base, name)
This approach keeps your internal representation consistent (I usually use str inside older modules) while still being friendly to callers.
Practical scenarios that deserve their own patterns
These are the situations where I see path bugs most often, and the patterns I use to avoid them.
Scenario 1: Reading base directories from environment variables
Environment variables are convenient, but they create messy inputs: trailing slashes, relative paths, ~, and sometimes empty strings.
Here‘s a pattern I like for a configurable directory:
import os
def getlogdir() -> str:
# Default to a relative path for local dev.
raw = os.environ.get("MYAPPLOGDIR", "./logs")
# Expand ~ and $VARS for human-friendly configs.
raw = os.path.expanduser(raw)
raw = os.path.expandvars(raw)
# Make absolute so logs don‘t move when the working directory changes.
return os.path.abspath(raw)
logdir = getlog_dir()
logpath = os.path.join(logdir, "app.log")
Two small notes from experience:
expanduser()is great for local tools, but on servers I still prefer explicit absolute paths.abspath()ties behavior to the current working directory, so decide whether you want ‘relative to where you ran the process‘ or ‘relative to the script/project.‘ For long-running services, I usually want stable absolutes.
Scenario 2: Creating directories safely before writing
os.path.join() builds the path; it doesn‘t ensure the directory exists.
For robust ‘write a file into a directory‘ code, I pair joining with directory creation:
import os
def writetextfile(dir_path: str, name: str, text: str) -> str:
# If name is supposed to be a filename, enforce that.
safe_name = os.path.basename(name)
fullpath = os.path.join(dirpath, safe_name)
os.makedirs(os.path.dirname(fullpath), existok=True)
with open(full_path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
return full_path
I use newline="\n" when I want consistent line endings in generated text files, especially when outputs are committed or compared in CI.
Scenario 3: Atomic-ish writes (avoid half-written files)
If your program can crash mid-write, or multiple processes might read the file while you‘re writing it, a direct open(path, "w") can produce partial files.
A safer pattern is ‘write to a temp file next to the target, then replace‘:
import os
import tempfile
def atomicwritetext(target_path: str, text: str) -> None:
dirname = os.path.dirname(targetpath)
os.makedirs(dirname, existok=True)
fd, tmppath = tempfile.mkstemp(prefix=".tmp-", dir=dirname)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
os.replace(tmppath, targetpath)
finally:
# If replace failed, try to clean up.
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
The key path point: I never build temp paths with string concatenation. I let tempfile handle it, and I join only for predictable, controlled components.
Scenario 4: CLI tools with user-provided output directories
Command-line flags like --output-dir are common. The trick is deciding what inputs you accept.
- If the flag is an output directory, accept directories.
- If the flag is an output file, accept a file path.
Where people go wrong is mixing these and then doing join(output, filename) blindly.
My approach:
import os
def computeoutputpath(outputdir: str, reportname: str) -> str:
# Accept output_dir as a directory path.
outdir = os.path.abspath(os.path.expanduser(outputdir))
# report_name is treated as a filename, not a path.
name = os.path.basename(report_name)
return os.path.join(out_dir, name)
That one basename() line prevents ‘absolute resets earlier parts‘ and also avoids accidental nested output directories.
Scenario 5: Project-relative resources (without relying on cwd)
If you ship a script that reads config/settings.toml, relying on the working directory is asking for trouble.
I still use the file anchor pattern when I don‘t want a full packaging solution:
import os
ROOT = os.path.dirname(os.path.abspath(file))
CONFIG = os.path.join(ROOT, "config", "settings.toml")
If I‘m inside a package (not just a loose script), I often switch to importlib.resources for data files. But if the ask is purely ‘build this path correctly,‘ join() + file is straightforward.
When I do (and don‘t) normalize paths
Normalization can be helpful, but I treat it as a deliberate step.
When I normalize
- When I accept human-written config values that may include extra slashes.
- When I produce logs or debug output and want stable, readable paths.
- When I enforce containment checks (usually using
pathlibandresolve()).
When I avoid normalization
- When the path is going to be passed to a tool that expects a specific formatting (rare, but it happens).
- When I want to preserve user intent exactly (for example, showing the path they typed back to them).
For string-based normalization:
import os
p = os.path.normpath(os.path.join(base, rel))
For filesystem-aware resolution (symlinks matter):
from pathlib import Path
p = (Path(base) / rel).resolve()
That difference matters in security-sensitive code and in environments with symlinks.
Testing os.path.join() behavior (the part teams skip and regret)
I like to keep path-joining logic boring. Tests are one way to keep it boring.
Unit tests for ‘absolute reset‘ rules
If your function expects a filename, test that it rejects or sanitizes absolute paths:
import os
def buildoutputpath(outputdir: str, filename: str) -> str:
filename = os.path.basename(filename)
return os.path.join(outputdir, filename)
def testbuildoutputpathstrips_directories():
assert buildoutputpath("/srv/out", "../x.txt").endswith("x.txt")
assert buildoutputpath("/srv/out", "/etc/passwd").endswith("passwd")
On Windows, I‘d also add a test for drive-ish values if I‘m accepting inputs from outside.
Separate tests for Windows semantics
If you can run a Windows CI job, I do it. There are Windows behaviors you won‘t reliably catch otherwise.
In cross-platform test suites, I usually structure tests as:
- ‘Platform-agnostic contract tests‘ (sanitization and containment logic).
- ‘Platform-specific semantics tests‘ (drive/UNC behavior), run only on Windows.
The point isn‘t to test Python; it‘s to lock in the behavior your code expects.
When NOT to use os.path.join()
This is a surprisingly practical section, because I see people over-apply join logic.
1) URLs are not filesystem paths
Don‘t do this:
import os
url = os.path.join("https://example.com", "api", "v1")
On Windows, you‘ll get backslashes, which is the opposite of what you want.
Use URL tools (urllib.parse.urljoin) or straightforward string formatting for URLs.
2) Cloud object keys are not filesystem paths
S3 keys, GCS object names, and many blob storage identifiers look like paths, but they are not OS paths. Their separator rules are service-defined, not platform-defined.
If you‘re building an S3 key, it‘s usually correct to join with / explicitly, regardless of OS.
3) You need validation, not joining
If you‘re handling user uploads, exports, or anything involving untrusted input, your primary tool isn‘t join(). It‘s containment enforcement and normalization (often with pathlib). I still use join() sometimes, but only after I‘ve clarified what I‘m joining (filename vs path fragment vs absolute override).
A small helper I reuse: joining a directory with a filename safely
When I‘m writing legacy-style os.path code and I want to enforce ‘this is a filename,‘ I often reach for a tiny helper like this:
import os
def joindirandfilename(dirpath: str, name: str) -> str:
# Treat name as a filename, not a path.
# – strips any directories
# – prevents absolute reset behavior
# – avoids accidental traversal segments
safe = os.path.basename(name)
return os.path.join(dir_path, safe)
This doesn‘t solve all security problems (for example, it doesn‘t validate characters, reserved names, or unicode edge cases), but it blocks a common class of production bugs: ‘why did my export go to the root directory?‘ and ‘why did my service overwrite the wrong file?‘
Debugging path bugs: how I actually investigate
When something path-related fails, I follow a very specific playbook.
1) Print repr(path) not just path
Invisible characters are real: trailing spaces, \n, and escape sequences can sneak in.
print(repr(full_path))
If the output surprises you, you likely have an upstream parsing issue.
2) Log the working directory and relevant environment variables
The working directory is a hidden input to relative paths.
import os
print("cwd:", os.getcwd())
print("HOME:", os.environ.get("HOME"))
On Windows, I‘d also log things like USERPROFILE when user home logic matters.
3) Confirm whether you meant ‘directory‘ or ‘file‘
Half the time, the bug is semantic: something called output_path is sometimes a directory and sometimes a filename.
I fix that by renaming variables and narrowing function contracts:
output_dirmeans directoryoutput_filemeans full file pathnamemeans filename only
4) Explicitly check isabs() before joining untrusted components
This is a simple guard that catches many surprises:
import os
if os.path.isabs(fragment):
raise ValueError("absolute paths not allowed")
On Windows, remember ‘isabs‘ has Windows semantics, so it may treat rooted paths and UNC paths as absolute.
A short checklist I keep in my head
When I‘m about to join paths in code that might run outside my laptop, I ask myself:
- Is this input a filename or a path fragment?
- Could the next component be absolute (and reset earlier parts)?
- Am I mixing
strandbytes? - Do I need normalization (
normpath) or resolution (realpath/Path.resolve) or neither? - If this input is untrusted, have I enforced containment inside a base directory?
If I can answer those clearly, os.path.join() behaves exactly the way I want.
Final take
os.path.join() is one of those deceptively small tools that carries a lot of platform knowledge for you. It‘s not glamorous, but it‘s the difference between code that quietly works everywhere and code that fails only on a Windows runner at 2 a.m.
I still use it daily, especially in scripts and legacy modules. In new projects, I often prefer pathlib for readability and safer containment patterns. Either way, the important part is the same: treat paths as structured values with rules, not as strings you can glue together.


