The deployment script that breaks on a shortcut
You are writing a deployment tool. Your application expects a configuration file at /var/lib/myapp/config.json. During local development, you want that path to point to a file in your home directory so you can edit it without sudo. You create a symbolic link. Your script calls os.Open and reads the file. It works perfectly.
Now you add a security check. You want to verify the file permissions before reading. You call os.Stat to grab the mode bits. The permissions look fine. You open the file. The read fails because the symlink points to a broken target, or worse, the symlink points to a file with completely different permissions than the link itself. Or you try to create a backup by copying the file, but you accidentally copy the link instead of the data, and your backup is just a hollow pointer.
Go's os package gives you precise control over links, but it forces you to choose between following the link and inspecting the link itself. The standard library splits these operations into distinct functions. Using the wrong one leads to subtle bugs where your code treats a shortcut as the real file, or fails silently when a link is broken.
Symlinks and hard links in plain terms
A symbolic link is a file that contains a path to another file. It is a pointer. The operating system treats the symlink as a small file holding a string. When you access the symlink, the kernel reads that string and redirects the operation to the target. If the target is deleted, the symlink remains, but it points to nothing. Symlinks can cross filesystem boundaries. They can point to directories. They can point to files on a different disk.
A hard link is a directory entry that points to the same inode as the original file. An inode is the internal database record that stores file metadata and the location of data blocks. A directory is just a list of names mapping to inodes. A hard link adds a second name to that list. There is no distinction between the "original" and the "link." Both names are equal. If you delete one name, the data stays alive as long as another name exists. Hard links cannot cross filesystem boundaries. Most systems do not allow hard links to directories.
Think of a symlink as a sticky note with an address written on it. You can move the note anywhere, but if the address is wrong, the note is useless. Think of a hard link as a second key to the same safe. Both keys open the safe. Destroying one key does not lock the safe.
Creating and inspecting symlinks
Here is the basic pattern for creating a symlink and verifying it exists without following the target. The code uses os.Symlink to create the link and os.Lstat to inspect the link itself.
package main
import (
"fmt"
"os"
)
func main() {
// Create a symlink: target first, then the link name.
// This writes a file named "link.txt" containing the path "target.txt".
err := os.Symlink("target.txt", "link.txt")
if err != nil {
fmt.Println(err)
return
}
// Lstat inspects the link itself, not the target.
// Stat would follow the link and return info about target.txt.
info, err := os.Lstat("link.txt")
if err != nil {
fmt.Println(err)
return
}
// Check the mode bits to confirm the file is a symlink.
// ModeSymlink is a bitmask constant defined in the os package.
if info.Mode()&os.ModeSymlink != 0 {
fmt.Println("link.txt is a symbolic link")
}
}
os.Symlink takes two arguments. The first is the target path. The second is the name of the link to create. This order is the opposite of what some other languages use. If you swap the arguments, you create a link with the wrong name pointing to the wrong place. The function returns an error if the link already exists or if the target path is invalid.
os.Lstat returns file information without following symlinks. If you call os.Stat on a symlink, the kernel follows the link and returns information about the target. If the target is missing, os.Stat returns an error. os.Lstat works even if the target is gone. It returns the metadata of the symlink file itself.
The mode check uses a bitwise AND operation. info.Mode() returns a value with multiple flags set. os.ModeSymlink is one of those flags. The expression info.Mode()&os.ModeSymlink != 0 evaluates to true only if the symlink bit is set. This is the standard way to test file types in Go. The os package provides helper methods like info.Mode().IsDir() and info.Mode().IsRegular(), but there is no IsSymlink() method. You must check the bitmask directly.
Symlinks are strings. Hard links are inodes. Know the difference.
Hard links and the inode model
Hard links share the same inode as the original file. This means they share the same data blocks, permissions, and timestamps. Modifying the file through one link updates the data visible through all links. The link count in the inode tracks how many directory entries point to it. When the count drops to zero, the kernel frees the data.
Here is how to create a hard link and verify it shares the inode with the source. The code uses os.Link to create the link and compares the inode numbers.
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
// Create a hard link: source first, then the new name.
// This adds a second directory entry pointing to the same inode.
err := os.Link("source.txt", "hardlink.txt")
if err != nil {
fmt.Println(err)
return
}
// Lstat retrieves metadata for both paths.
// Both calls return info about the same underlying file.
srcInfo, err := os.Lstat("source.txt")
if err != nil {
fmt.Println(err)
return
}
linkInfo, err := os.Lstat("hardlink.txt")
if err != nil {
fmt.Println(err)
return
}
// Sys() returns the underlying system-specific stat structure.
// On Unix, this is a *syscall.Stat_t with the Ino field.
// The type assertion handles the platform difference safely.
if srcIno, ok := srcInfo.Sys().(*syscall.Stat_t); ok {
if linkIno, ok := linkInfo.Sys().(*syscall.Stat_t); ok {
if srcIno.Ino == linkIno.Ino {
fmt.Println("Both paths share the same inode")
}
}
}
}
os.Link creates a hard link. The first argument is the existing file. The second is the new name. The function fails if the source does not exist, if the destination already exists, or if the source and destination are on different filesystems. The error message for the cross-device case is invalid cross-device link. This is a hard limit of the filesystem. Hard links cannot point to data on another mount point.
The inode comparison uses Sys() to access platform-specific data. On Unix-like systems, the return value is a pointer to syscall.Stat_t. The Ino field holds the inode number. On Windows, the structure is different. Hard links exist on Windows but the API differs. The type assertion srcInfo.Sys().(*syscall.Stat_t) returns false on non-Unix systems, preventing a panic. This pattern is necessary when you need low-level details like inode numbers.
Hard links stay on one filesystem. Symlinks travel.
Resolving paths in real code
Real applications often need to resolve symlinks to find the canonical path. For example, a web server might need to ensure a requested file is inside a specific directory, even if the request uses symlinks to try to escape. Go provides os.EvalSymlinks to resolve the full path, following every symlink in the chain.
Here is a helper that resolves a path and checks if the result is inside a safe directory. The code uses os.EvalSymlinks to get the absolute path and string comparison to enforce the boundary.
package main
import (
"fmt"
"os"
"strings"
)
// IsInside checks if the resolved path is inside the allowed base directory.
// It returns true only if the canonical path starts with the base path.
func IsInside(path, base string) (bool, error) {
// EvalSymlinks resolves the path completely, following all symlinks.
// It returns an error if any component is missing or creates a cycle.
resolved, err := os.EvalSymlinks(path)
if err != nil {
return false, fmt.Errorf("resolve %s: %w", path, err)
}
// Resolve the base directory as well to handle relative inputs.
// This ensures both paths are absolute and canonical.
baseResolved, err := os.EvalSymlinks(base)
if err != nil {
return false, fmt.Errorf("resolve base %s: %w", base, err)
}
// Check if the resolved path starts with the base directory.
// Add a separator to prevent prefix matches like /etc matching /etc-shadows.
return strings.HasPrefix(resolved, baseResolved+string(os.PathSeparator)), nil
}
os.EvalSymlinks returns the absolute path with all symlinks resolved. It handles relative paths and resolves .. components. If the path contains a broken symlink, the function returns an error. If the path contains a circular symlink, the function returns an error with the message too many levels of symbolic links. This prevents infinite loops.
The prefix check adds a path separator to the base directory. Without the separator, a path like /etc-shadows would incorrectly match a base of /etc. This is a common security pitfall. Always include the separator when checking directory containment.
Security-conscious code often needs to distinguish between a real file and a link. os.Lstat is the defense against symlink attacks where a link points to a sensitive file. Check the mode bits before opening files in untrusted directories.
Lstat stops at the link. Stat follows it.
Pitfalls and compiler errors
The argument order for os.Symlink is Symlink(target, link). This is the reverse of os.Link, which uses Link(old, new). Mixing these up creates links that point to the wrong place. The compiler does not catch this error. The types are both strings. You get the bug at runtime.
os.Stat follows symlinks. If you call Stat on a broken symlink, you get an error like no such file or directory. Use Lstat when you need to detect broken links or inspect the link itself. Lstat returns information about the symlink even if the target is missing.
Hard links cannot point to directories on most systems. Attempting to create a hard link to a directory fails with operation not permitted or invalid cross-device link depending on the filesystem. Use symlinks if you need to link directories.
os.Readlink reads the target path stored inside a symbolic link. It returns the string content of the link. It does not resolve the path. If the link contains a relative path, Readlink returns that relative path. Use os.EvalSymlinks if you need the absolute resolved path.
The compiler rejects unused imports with imported and not used. If you import syscall for the inode check but remove the code, the build fails. Remove the import or use a blank identifier _ to suppress the error. The blank identifier discards the value intentionally. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors. Dropping an error without checking it hides bugs.
The community convention for error handling is explicit. Check every error immediately. Return or log the error. The verbose if err != nil pattern makes the unhappy path visible. Do not wrap errors in silent ignores. Go prefers clarity over cleverness. You will see this pattern everywhere in the standard library and in production codebases.
Goroutines are cheap. Channels are not magic. File operations are blocking. Run long file scans in a goroutine if they might delay the main loop.
When to use symlinks, hard links, and Lstat
Use a symbolic link when you need to point to a file across different filesystems or mount points. Use a symbolic link when you want to create a pointer that can be updated to point to a new target without changing the link name. Use a symbolic link when you need to link directories, since hard links to directories are generally forbidden. Use os.Lstat when you need to inspect a path and must distinguish between a link and its target. Use os.Lstat when you need to detect broken symlinks, since os.Stat fails on missing targets. Use a hard link when you need multiple names for the same file on the same filesystem and want the data to persist even if one name is deleted. Use a hard link in backup tools to save disk space by sharing unchanged data between versions. Use os.EvalSymlinks when you need the canonical absolute path with all symlinks resolved. Use os.Readlink when you need to read the target path stored inside a symbolic link without resolving it. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Trust the inode. Verify the mode. Follow the links only when you intend to.