🍱 Lunchbox Hands

linux

umask Explained: Why New Files Are 644 and New Directories 755

Why a new file is 644 and a new directory 755, why umask 023 produces 644 and not 643, why the symbolic form looks backwards, and the commands that ignore your umask completely.

Create a file on any Unix system and it arrives as 644. Create a directory and it arrives as 755. Nobody chose those numbers — no flag was passed, no config file mentions them — and yet they’re the same on your laptop, on the CI runner, and on the server. The number you get is not a default. It’s the result of a subtraction that isn’t a subtraction, applied to a starting value that no program ever writes down. Once you can compute it in your head, a whole category of “why is this file 600 on the server and 644 locally” bugs stops being mysterious.

All of the output below was executed on macOS 26.2 with bash 3.2 and coreutils-equivalent BSD tools; the numbers are identical on Linux unless noted.

The two starting points: 666 and 777

Every program that creates a file passes a requested mode to open(2), and almost every program passes the same one: 0666 for a regular file, 0777 for a directory. That’s rw-rw-rw- and rwxrwxrwx — deliberately maximal, because the kernel is about to take permissions away.

What takes them away is the process’s file mode creation mask, the umask. POSIX defines it in one sentence: “Bit positions that are set in cmask are cleared in the mode of the created file.” The Linux man page gives the same rule as a formula — “permissions in the umask are turned off from the mode argument to open(2) and mkdir(2)” — and notes that “the typical default value for the process umask is S_IWGRP | S_IWOTH (octal 022)”.

Put the three numbers together and the mystery evaporates:

Requested modeumaskResult
File666022644 (-rw-r--r--)
Directory777022755 (drwxr-xr-x)
$ umask
0022
$ touch f1; mkdir d1; stat -f "%Sp %N" f1 d1
-rw-r--r-- f1
drwxr-xr-x d1

$ umask 077; touch f3; mkdir d3; stat -f "%Sp %N" f3 d3
-rw------- f3
drwx------ d3

$ umask 002; touch f4; mkdir d4; stat -f "%Sp %N" f4 d4
-rw-rw-r-- f4
drwxrwxr-x d4

The three-digit permission result is the thing you can build and read in the chmod calculator — tick the boxes for rw-r--r-- and you get 644. What the calculator can’t do is the masking step; for that, the table below is the shortcut.

It’s a mask, not a subtraction

Almost every tutorial says “umask is subtracted from 666.” That works for the common values and then quietly lies to you. The real operation is a bitwise AND-NOT: mode & ~umask.

Watch it break with umask 023. Subtraction would predict 666 - 023 = 643:

$ umask 023; touch f5; mkdir d5; stat -f "%Sp %N" f5 d5
-rw-r--r-- f5      # 644, not 643
drwxr-xr-- d5      # 754

Digit by digit, the “other” column is 6 (110) masked by 3 (011). Subtraction gives 3. Masking clears the write bit (already the only one that overlaps) and leaves 100 = 4:

DigitRequestedMask& ~maskSubtraction would say
6 = rw-1103 = 011100 = 4 (r--)3 (-wx)
7 = rwx1113 = 011100 = 4 (r--)4 — coincidence

A mask can only ever clear bits it has set. It can never grant a permission, and it can never produce a permission the requesting program didn’t ask for — which is why umask 023 can’t hand out an execute bit the way naive subtraction implies.

The symbolic form looks backwards because it is

umask accepts and prints a symbolic form, and it is the exact inverse of the numeric one: the numeric form lists the bits you remove, the symbolic form lists the bits you keep.

$ umask 027; umask -S
u=rwx,g=rx,o=

$ umask u=rwx,g=,o=; umask
0077

So umask 022 and umask u=rwx,g=rx,o=rx are the same setting written two ways. If you’ve ever set umask g=rwx expecting to strip group permissions and got the opposite, this is why. Both forms work identically in zsh, which just prints the numeric form without the leading zero.

Where your umask actually comes from

The umask is a property of a process, not of a user or a directory. The man page is explicit: “A child process created via fork(2) inherits its parent’s umask. The umask is left unchanged by execve(2).” Everything follows from that.

Every language exposes the same inherited value:

$ umask 027
$ python3 -c "import os; os.mkdir('pyd'); print(oct(os.stat('pyd').st_mode & 0o7777))"
0o750
$ node -e "fs.writeFileSync('nf','x'); console.log((fs.statSync('nf').mode & 0o7777).toString(8))"
640

What ignores your umask

The mask only applies when a file is created with a requested mode. A surprising number of everyday operations sidestep it:

OperationUnder umask 077Why
touch new600Created with 0666, masked
echo x > new600Same — shell redirection creates with 0666
echo x > existingunchangedTruncation isn’t creation; mode is left alone
chmod 666 f666chmod sets modes explicitly; umask never applies
mkdir -m 755 d755Explicit mode, applied after creation
install -m 755 src dst755Same
cp src dst700 from a 777 sourceNew file, mode requested from source, then masked
cp -p src dst777-p preserves the source mode
tar xf a.tar700 for a 777 memberModes masked for non-root
tar xpf a.tar777-p restores stored permissions

Measured, not assumed:

$ umask 077
$ mkdir -m 755 dm; stat -f "%Sp %N" dm
drwxr-xr-x dm
$ cp src777 cp777; stat -f "%Sp %N" src777 cp777
-rwxrwxrwx src777
-rwx------ cp777
$ cp -p src777 cpp777; stat -f "%Sp %N" cpp777
-rwxrwxrwx cpp777
$ tar xf a.tar; stat -f "%Sp %N" archived      # bsdtar 3.5.3
-rwx------ archived
$ rm archived; tar xpf a.tar; stat -f "%Sp %N" archived
-rwxrwxrwx archived

Zip is the odd one out. With Info-ZIP unzip 6.00, a member stored as 755 came back as 755 even under umask 077 — the stored mode won. If you rely on “extraction is safe because the umask will tighten it,” verify it on the exact tool you ship with, because tar and unzip disagree.

The bits umask can never reach

Files never get an execute bit from creation. The requested mode is 0666, and a mask can only clear. That’s why a script you just wrote is never runnable until you chmod +x it, no matter how permissive the umask:

$ umask 000; touch wide; stat -f "%Sp %N" wide
-rw-rw-rw- wide

setuid, setgid, and sticky are out of scope too. POSIX says “Only the file permission bits of cmask are used; the meaning of the other bits is implementation-defined.” So the leading digit in 0022 is decoration — and asking the kernel for them at creation doesn’t work either:

$ umask 0022
$ python3 -c "import os; fd=os.open('suid', os.O_CREAT|os.O_WRONLY, 0o7777); os.close(fd)"
$ stat -f "%Sp" suid      # 0755 — the setuid/setgid/sticky bits were dropped
-rwxr-xr-x

Those special bits are a separate topic entirely, covered in chmod 755 vs 644 along with the recursive-chmod mistake.

Group ownership isn’t a umask question either, and the answer differs by platform. On macOS a new file inherits the directory’s group (BSD semantics); on Linux it gets the process’s effective group unless the directory has the setgid bit:

$ id -gn
staff
$ ls -ld gdir | awk '{print $3, $4}'
kylehurst wheel
$ touch gdir/inner; ls -l gdir/inner | awk '{print $3, $4}'
kylehurst wheel      # inherited from the directory, not from my egid

If a shared upload directory keeps producing files the web server can’t read, check the group and the setgid bit before you touch the umask.

The lookup table

umaskNew filesNew directoriesTypical use
000666777Never. World-writable everything
002664775Shared group work, user-private-group systems
022644755The standard default
027640750Group reads, world sees nothing
077600700Private: SSH keys, credentials, backups
023644754Proof it’s a mask, not a subtraction

Debugging checklist

  1. umask in the shell tells you about that shell, and nothing else. Check the umask of the process that actually wrote the file — cat /proc/<pid>/status | grep Umask on Linux ≥ 4.7.
  2. If the writer is a systemd unit, set UMask= in the unit; the shell’s value is irrelevant.
  3. If the file is fine but the directory isn’t traversable, you want the execute bit on the directory — permissions on the path matter as much as on the file.
  4. If the permissions look right and access still fails, look past the mode bits: ACLs (ls -le on macOS, getfacl on Linux), SELinux labels, and mount options such as noexec all override the mode you’re staring at.
  5. Never “fix” it with chmod -R 777. Fix the creating process’s umask, or set the mode explicitly at creation with install -m.

Build and check the target permissions with the chmod calculator, then set them deliberately — and read chmod 755 vs 644 if you want the ls -l columns and the symbolic chmod forms in one place.