﻿# PowerShell: files as objects, switch parameters and hashtables

> Get-ChildItem returns file objects, so size, date and extension are properties to filter and sort by instead of columns to cut out of text. A hashtable answers "how many per …" and "which has the most …" in a few lines – with a composite key when one field is not enough, or with Group-Object in a pipeline.

Source: https://laszlonemes.com/university/computer-systems/powershell-files-hashtables · Author: László Nemes

Session 10 · Computer Systems

Get-ChildItem returns file objects, so size, date and extension are properties to filter and sort by instead of columns to cut out of text. A hashtable answers "how many per …" and "which has the most …" in a few lines – with a composite key when one field is not enough, or with Group-Object in a pipeline.

20 min read `File objects: Length, Extension, LastWriteTime` `New-Item, Join-Path, Copy-Item, -WhatIf` `Aliases that exist only on Windows` `[switch], defaults and array parameters` `Hashtables and composite keys` `Group-Object`

Much of PowerShell is a shell like any other with a different spelling: variables, conditions, loops, a pipe. Two things are not. The first is the file system: `Get-ChildItem` hands you **file objects**, so a file's size, date and extension are properties you ask for, not columns you cut out of a line of text. The second is the **hashtable**, a key–value store that turns "how many per …" and "which has the most …" questions into a few lines.

This page takes both from the ground up, together with the script parameters that go with them: switches, default values and arrays. It assumes you can write a basic script – a `param()` block, `Test-Path`, a loop over `Get-Content`; if not, start with [the introduction to PowerShell](https://laszlonemes.com/university/computer-systems/powershell-basics).

After this session you can

- read a file's name, extension, size and modification time from its object, and filter and sort files by them
- create directories and copy, move and delete files in a script that also runs on Linux and macOS
- say which aliases are safe in a script and why `ls` is not one of them
- give a script a switch (`-InPlace`), a default value and an array parameter
- sum and count per key in a hashtable, with a composite key when one field isn't enough
- solve the same counting task with `Group-Object` in a pipeline

> **The idea in one sentence:** stop parsing text when PowerShell already has the value – a file knows its own `Length`, a date knows how to compare itself, and a hashtable knows whether it has seen a key before.

## The files used on this page

Downloads

**Example and task files**

The `mixed_files` directory – eight small files of different types and sizes – is what the examples below list, filter and sort; the other five are inputs of the tasks. If you download them one by one, put the eight `mixed_files/…` files into a directory of that name.

[mixed_files/README](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/README) · [mixed_files/app.log](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/app.log) · [mixed_files/archive.TXT](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/archive.TXT) · [mixed_files/big.log](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/big.log) · [mixed_files/data.csv](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/data.csv) · [mixed_files/notes.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/notes.txt) · [mixed_files/prices.csv](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/prices.csv) · [mixed_files/todo.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/todo.txt) · [neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/neptun_codes.txt) · [read.me](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/read.me) · [print_log.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/print_log.txt) · [patients_messy.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/patients_messy.txt) · [numbers_spaces.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/numbers_spaces.txt)

Or all at once, from the terminal – the files land in the current directory:

Linux, macOS, the course server

```shell
curl --create-dirs -o "#1" "https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/{mixed_files/README,mixed_files/app.log,mixed_files/archive.TXT,mixed_files/big.log,mixed_files/data.csv,mixed_files/notes.txt,mixed_files/prices.csv,mixed_files/todo.txt,neptun_codes.txt,read.me,print_log.txt,patients_messy.txt,numbers_spaces.txt}"
```

Windows (PowerShell, cmd)

```powershell
curl.exe --create-dirs -o "#1" "https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/{mixed_files/README,mixed_files/app.log,mixed_files/archive.TXT,mixed_files/big.log,mixed_files/data.csv,mixed_files/notes.txt,mixed_files/prices.csv,mixed_files/todo.txt,neptun_codes.txt,read.me,print_log.txt,patients_messy.txt,numbers_spaces.txt}"
```

Look at the data files freely, but don't save them from an editor: their line endings and last bytes are part of the exercise, and an editor may "fix" them.

Work in the directory that holds these files, and run your scripts from **inside** PowerShell 7 (`pwsh`), not from bash: an array parameter such as `txt,csv` only arrives as an array when PowerShell itself parses the command line.

Common mistake

**Some of the tasks change files**

A script that moves files or rewrites one in place leaves the inputs different from what the next task expects. Try those on a copy – `Copy-Item mixed_files test_dir -Recurse`, `Copy-Item neptun_codes.txt test.txt` – and delete the copy afterwards. Whatever your runs create (`users`, `passwords.txt`, `test_dir` …) is safe to remove.

## Files are objects

In a Unix shell a file listing is text. To get the sizes you have to know that in the output of `ls -l` the size is the fifth column, cut it out, and hope no file name contains a space. Or you start a separate program for every question: one for the size, another for the date, a third for filtering.

```shell
# bash: the size is "the fifth column" of a line of text
ls -l mixed_files | awk '{ print $5, $9 }'
```

`Get-ChildItem` returns one **object** per file, and the object's data has names (Fig. 1): `$file.Length` is a number, `$file.LastWriteTime` is a date. Nothing has to be counted, cut or converted.

> **Fig. 1** · The same file, twice: as a line of text whose fifth column happens to be the size, and as an object with a `Length` property.
>
> *Diagram:* In bash, ls -l prints a line of text and the file size is whatever stands in the fifth column, which you have to count and cut out. In PowerShell, Get-ChildItem returns a file object whose size is the property called Length, next to Name, Extension, LastWriteTime and FullName.

```powershell
$file = Get-Item mixed_files/notes.txt

$file.Name              # notes.txt
$file.BaseName          # notes       (the name without the extension)
$file.Extension         # .txt        (with the dot; an empty string for README)
$file.Length            # 60          (bytes – a number, not text)
$file.LastWriteTime     # a date object, not a string
$file.FullName          # the absolute path
```

| Property | What it holds |
| --- | --- |
| `Name` | the file name: `notes.txt` |
| `BaseName` | the name without the extension: `notes` |
| `Extension` | the extension **with the dot**: `.txt`; an empty string if there is none |
| `FullName` | the absolute path |
| `Length` | the size in bytes, as a number |
| `LastWriteTime` | when it was last modified, as a date object |

`Get-Item` gives the object of one path; `Get-ChildItem` gives the entries of a directory. By default that includes subdirectories – add `-File` whenever you mean files, or the first directory in the list breaks your size arithmetic.

```powershell
Get-ChildItem -Path mixed_files                  # files and directories
Get-ChildItem -Path mixed_files -File            # files only
Get-ChildItem -Path mixed_files -Directory       # directories only
Get-ChildItem -Path mixed_files -File -Recurse   # files in the whole tree below it

foreach ($file in Get-ChildItem -Path mixed_files -File) {
    Write-Output "$($file.Name): $($file.Length) bytes"
}
```

Common mistake

**A property inside a string needs $( )**

Inside double quotes PowerShell substitutes the variable and stops at the dot. The property is only evaluated inside `$( )`.

```powershell
"Size: $file.Length"       # WRONG: the whole path, then the text ".Length"
"Size: $($file.Length)"    # Size: 60
```

### Dates and sizes

`LastWriteTime` and the result of `Get-Date` are date objects. They have methods (`AddDays`, `AddHours`, `ToString` with a format) and they compare with the usual operators – "modified in the last 7 days" is a single `-ge`.

```powershell
$now = Get-Date                          # this moment, as a date object
$limit = $now.AddDays(-7)                # the same moment, seven days ago

$file.LastWriteTime -ge $limit           # True if modified in the last 7 days: dates compare like numbers
$file.LastWriteTime.ToString('yyyy-MM-dd HH:mm')    # 2026-11-11 14:45

$file.LastWriteTime = $now.AddDays(-10)  # the property can be assigned too: the file is now "ten days old"
```

Sizes are plain numbers of bytes. PowerShell understands the suffixes `KB`, `MB` and `GB` on a number, in a script and on the command line alike:

```powershell
1KB                          # 1024
1MB                          # 1048576
$file.Length -gt 1KB         # is the file larger than 1024 bytes?
```

### A pipeline of files

Because objects travel through the pipe, every stage can refer to a property by name. `Where-Object` filters, `Sort-Object` orders, `Select-Object` picks properties or the first few items, `Measure-Object` adds up. Inside the braces, `$_` is the object passing through (Fig. 2).

```powershell
Get-ChildItem -Path mixed_files -File |
    Where-Object { $_.Length -gt 50 } |
    Sort-Object -Property Length -Descending |
    Select-Object -Property Name, Length
```

```text
Name      Length
----      ------
big.log     3360
app.log       84
notes.txt     60
```

> **Fig. 2** · Eight file objects go in; the filter and the sort both use `Length`. No stage ever sees a line of text.
>
> *Diagram:* A pipeline of file objects over the mixed_files directory. Get-ChildItem -File returns eight file objects. Where-Object keeps the three whose Length property is greater than 50: app.log, big.log and notes.txt. Sort-Object orders them by Length, largest first: big.log 3360, app.log 84, notes.txt 60. Every stage refers to the size by the property name, never by a column position.

```powershell
Get-ChildItem mixed_files -File | Where-Object Length -gt 50      # one property, one comparison: no { } and no $_
Get-ChildItem mixed_files -File | Measure-Object -Property Length -Sum    # Count: 8, Sum: 3656
```

When the result goes into a variable, wrap the pipeline in `@( )`. A pipeline returns an array for several items, the bare item for one, and nothing for none; `@( )` makes it an array in all three cases, so the code after it doesn't have to care.

```powershell
$large = @(Get-ChildItem -Path mixed_files -File | Where-Object { $_.Length -gt 1KB })

$large.Count          # 1 – an array with one element; without @( ) it would be a bare file object,
                      #     and with no match at all it would be $null
```

| Question | Pipeline |
| --- | --- |
| files larger than 1 KB | `Get-ChildItem -File \| Where-Object { $_.Length -gt 1KB }` |
| modified in the last 7 days | `… \| Where-Object { $_.LastWriteTime -ge (Get-Date).AddDays(-7) }` |
| largest first | `… \| Sort-Object -Property Length -Descending` |
| the three largest | `… \| Sort-Object -Property Length -Descending \| Select-Object -First 3` |
| number of files and total size | `… \| Measure-Object -Property Length -Sum` |
| only the .txt files | `Get-ChildItem -File -Filter *.txt` |

## Creating, copying, moving and deleting

The cmdlets that change the file system follow the Verb-Noun pattern and all work on an *item* – a file or a directory.

| To … | Cmdlet | Note |
| --- | --- | --- |
| create a directory | `New-Item -Path users -ItemType Directory` | an error if it already exists |
| create it with its parents | `New-Item -Path users/a/b -ItemType Directory -Force` | no error if it exists |
| test for a directory | `Test-Path -Path $dir -PathType Container` | for a file: `-PathType Leaf` |
| copy a file | `Copy-Item -Path read.me -Destination users` | overwrites a file of the same name |
| copy a directory | `Copy-Item -Path src -Destination dst -Recurse` | with everything in it |
| move or rename | `Move-Item -Path notes.txt -Destination txt` |  |
| delete | `Remove-Item -Path test_dir -Recurse` | permanent – there is no recycle bin |

Two details make such a script behave. `New-Item` returns the object it created, and anything a script returns is printed – pipe it into `Out-Null` to keep the output clean. And build paths with `Join-Path` instead of gluing strings with a slash: it uses the separator of the system it runs on.

```powershell
$userDir = Join-Path -Path $TargetDir -ChildPath $id          # users/RVR55V – users\RVR55V on Windows

if (-not (Test-Path -Path $userDir -PathType Container)) {    # only if it isn't there yet
    New-Item -Path $userDir -ItemType Directory | Out-Null    # Out-Null: don't print the new directory
}
```

Common mistake

**Try a destructive command with -WhatIf first**

`Remove-Item`, `Move-Item` and `Copy-Item` accept `-WhatIf`: the command only prints what it *would* do. One extra run costs nothing; a recursive delete with a wrong variable in the path can't be undone.

```powershell
Remove-Item -Path users -Recurse -WhatIf
# What if: Performing the operation "Remove Directory" on target ".../users".
```

## Aliases: which ones work everywhere

Cmdlets have short names, and at the prompt they save typing: `dir` for `Get-ChildItem`, `gc` for `Get-Content`, `?` for `Where-Object`, `%` for `ForEach-Object`. The aliases fall into two groups, and only one of them is the same on every system.

| Aliases | Where they exist |
| --- | --- |
| `gc`, `dir`, `gci`, `echo`, `cd`, `copy`, `move`, `del`, `md` | everywhere |
| `?`, `where`, `%`, `foreach`, `select`, `measure`, `group`, `sls` | everywhere |
| `ls`, `cat`, `cp`, `mv`, `rm`, `sort` | **only on Windows** – on Linux and macOS these names start the real Unix programs |

The last group is the trap. On Windows `ls` is `Get-ChildItem` and returns file objects. On Linux and macOS PowerShell leaves the name alone, so `ls` is `/bin/ls` and returns lines of text. A string has a `Length` too – the number of its characters – so the filter still runs and silently answers a different question (Fig. 3).

> **Fig. 3** · One command line, two meanings. With `ls`, `Length` is the file size on Windows and the length of the file *name* elsewhere.
>
> *Diagram:* The same line, ls piped into Where-Object Length -gt 50, means two different things. On Windows, ls is an alias of Get-ChildItem: it returns file objects, Length is the size in bytes, and the three large files come out. On Linux and macOS, ls is the real Unix program: it returns lines of text, Length is the length of each file name, and nothing comes out. Get-ChildItem, dir and gci return objects on every system.

```powershell
ls | Where-Object Length -gt 50               # Windows: the large files. Linux, macOS: nothing
Get-ChildItem | Where-Object Length -gt 50    # the large files, everywhere
```

Good to know

**cat is the harmless one**

`cat file.txt | …` works at the start of a pipeline on Linux and macOS as well: PowerShell passes the output of the real `cat` on line by line, as strings – which is what `Get-Content` gives too. In a script write `Get-Content` anyway: it says what it does, on every system.

In your assignment

**Full names in the script you hand in**

Don't let a script depend on the system it happens to run on. Write the cmdlet names out – `Get-ChildItem`, `Sort-Object`, `Copy-Item` – and keep aliases for the prompt. A script with full names means the same on every system and reads the same to everyone.

## Switches, defaults and array parameters

### A switch

An option that is either there or not – `-Recurse`, `-Force`, `-WhatIf` – is a parameter of type `[switch]`. It takes no value: it is `$true` when the caller writes it and `$false` when not, and PowerShell finds it wherever it stands on the command line. Your own scripts get one the same way. A typical use: a script that only *shows* its result by default, and changes the file when asked to.

```powershell
param(
    [string]$File,
    [switch]$InPlace            # no value: written = $true, left out = $false
)

# ... $codes: the cleaned lines of $File ...

if ($InPlace) {
    Set-Content -Path $File -Value $codes -Encoding UTF8     # change the file itself
} else {
    Write-Output $codes                                      # only show the result
}
```

```powershell
./tidy_codes.ps1 codes.txt              # $InPlace is $false: prints, the file is untouched
./tidy_codes.ps1 codes.txt -InPlace     # $InPlace is $true: rewrites codes.txt
./tidy_codes.ps1 -InPlace codes.txt     # a switch can stand anywhere
```

Writing back into the file you have just read is safe here, as long as the lines are already in a variable when the writing starts. No temporary file is needed.

### A default value – and "was it given at all?"

`= "."` after a parameter is its default. A typed parameter without a default is never empty: a missing `[int]` is `0`, which is also a perfectly valid value – so `if (-not $Days)` can't tell "left out" from "zero". `$PSBoundParameters` holds the parameters the caller really passed; ask it.

```powershell
param(
    [int]$Days,                       # no default: 0 when it is left out
    [string]$Directory = "."          # default: the current directory
)

if (-not $PSBoundParameters.ContainsKey('Days')) {    # "was it passed?" – not "is it non-zero?"
    Write-Error "Usage: $($MyInvocation.MyCommand.Name) <days> [directory]"
    exit 1
}
```

### An array parameter

Square brackets after the type make it an array: `[string[]]`, `[int[]]`. The caller separates the elements with **commas**; a space starts the next parameter.

```powershell
param(
    [string]$Directory,
    [string[]]$Extensions             # an array: txt,csv
)
```

```powershell
./sort_by_extension.ps1 test_dir txt,csv      # $Extensions has two elements
./sort_by_extension.ps1 test_dir txt csv      # WRONG: only txt arrives; csv is a separate argument and is ignored
```

Inside the script, `$Extensions -contains 'txt'` asks whether a value is in the array – no loop needed – and `-notcontains` is its negation. Like every comparison in PowerShell they ignore case.

## Random values

`Get-Random` returns a random whole number below `-Maximum`. Together with the fact that a string can be indexed like an array, that is all a password generator needs: pick a random position in a string of allowed characters, as many times as the password is long.

```powershell
Get-Random -Maximum 6                 # a whole number from 0 to 5
Get-Random -Minimum 1 -Maximum 7      # 1 to 6: the maximum itself is never returned

$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
$characters[0]                                           # a – a string can be indexed like an array
$characters[(Get-Random -Maximum $characters.Length)]    # one random character
```

## Hashtables: counting and summing per key

A **hashtable** stores one value per key, and the key can be any text – a name, a month, an extension. It is what other languages call a dictionary or a map, and what bash calls an [associative array](https://laszlonemes.com/university/computer-systems/text-processing). One property makes it pleasant for counting: a key that isn't there yet reads as `$null`, and `$null + 12` is `12`, so the first addition needs no special case.

```powershell
$pages = @{}                                           # an empty hashtable

$pages['Toth Eszter'] = $pages['Toth Eszter'] + 12     # a missing key is $null, and $null + 12 = 12
$pages['Toth Eszter'] = $pages['Toth Eszter'] + 25     # 37

$pages['Toth Eszter']                                  # 37 – the value stored under a key
$pages.Count                                           # 1  – the number of keys
$pages.Keys                                            # every key, in no particular order
$pages.ContainsKey('Varga Dora')                       # False
```

| Operation | Code |
| --- | --- |
| create | `$pages = @{}` |
| set a value | `$pages['Varga Dora'] = 30` |
| add to a key | `$pages[$name] = $pages[$name] + $n` |
| read a value | `$pages[$name]` |
| all keys | `$pages.Keys` |
| number of keys | `$pages.Count` |
| is the key there? | `$pages.ContainsKey($name)` |

### Sum per key, then the maximum

A print server keeps a log, [print_log.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/print_log.txt): who printed, how many pages, on which printer. The same person shows up in several lines.

print_log.txt

```text
Toth Eszter, 12, lab-2
Varga Dora, 30, library
Balogh Mate, 4, lab-1
Toth Eszter, 25, library

Szalai Anna, 18, lab-2
Varga Dora, 9, lab-1
Toth Eszter,8,lab-2
```

**Who printed the most pages?** The answer takes two loops. The first reads the lines and adds each page count to the value stored under the name (Fig. 4):

> **Fig. 4** · Three lines with the same name feed one entry. The key must be cleaned first – `"Toth Eszter"` and `"Toth Eszter "` would be two people.
>
> *Diagram:* Summing per key with a hashtable. Each line of print_log.txt is split into name, pages and printer; the page count is added to the value stored under the name. Toth Eszter appears in three lines, with 12, 25 and 8 pages, so her entry ends at 45 – the largest value. Varga Dora appears twice, 30 plus 9 is 39. A key that is not in the hashtable yet reads as null, and null plus a number is the number.

```powershell
foreach ($line in Get-Content -Path $File -Encoding UTF8) {
    $line = $line.Trim()
    if ($line -eq '') { continue }

    $name, $count, $printer = $line -split ','      # three fields, three variables
    $name = $name.Trim()                            # "Toth Eszter" – the space inside stays
    $count = "$count".Trim()                        # " 12" → "12"

    $pages[$name] = $pages[$name] + [int]$count     # [int]: the field is still text
}
```

The second loop walks the keys and remembers the largest value seen so far – the maximum search you have written in every language:

```powershell
$maxKey = ""
$maxCount = 0

foreach ($key in $pages.Keys) {
    if ($pages[$key] -gt $maxCount) {
        $maxCount = $pages[$key]
        $maxKey = $key
    }
}

Write-Output "$maxKey - $maxCount"      # Toth Eszter - 45
```

The keys come out in no particular order – never rely on it. For a report, pass the keys through `Sort-Object` and loop over the result:

```powershell
foreach ($name in ($pages.Keys | Sort-Object)) {
    Write-Output "${name}: $($pages[$name])"      # the braces keep the colon out of the variable name
}
```

```text
Balogh Mate: 4
Szalai Anna: 18
Toth Eszter: 45
Varga Dora: 39
```

### A composite key

"The most common illness *per month*" counts pairs: month and illness together. A hashtable has one key per entry, so glue the two fields into one string with a separator that can't occur in either – `10:fejfájás` – and split it again when you read the keys back (Fig. 5).

```powershell
$key = "${month}:$illness"            # "10:fejfájás" – one string made of two fields
$counts[$key] = $counts[$key] + 1

$month, $illness = $key -split ':'    # and apart again, when you read the keys back
```

> **Fig. 5** · Three steps, three loops: count under month:illness, keep the best illness of every month, print the months in sorted order.
>
> *Diagram:* The most common illness per month in three steps. Step one cleans every line and counts under a composite key made of month, a colon and the illness, for example 10:fejfájás with 3. Step two walks the keys, splits each at the colon and keeps the largest count per month in two more hashtables. Step three prints the months in sorted order, because a hashtable has no order of its own: month 05 lábfájás 3, month 09 gyomorpanaszok 2, month 10 fejfájás 3, month 11 megfázás 3.

Common mistake

**You can't change a hashtable while you loop over it**

Assigning to an entry inside `foreach ($key in $counts.Keys)` stops the script with *Collection was modified*. Loop over a copy of the keys instead – `@( )` makes one. Reading inside the loop is always fine; so is writing to a *different* hashtable.

```powershell
foreach ($key in $counts.Keys) {
    $counts[$key] = 0                 # ERROR: Collection was modified; enumeration operation may not execute.
}

foreach ($key in @($counts.Keys)) {   # @( ) makes a copy of the keys first
    $counts[$key] = 0
}
```

### The same with Group-Object

In a pipeline, `Group-Object` does the counting loop for you: it collects equal items and returns one object per group, with the properties `Name` (the key), `Count` and `Group` (the items themselves). If you know the Unix pipeline `sort | uniq -c`, this is its counterpart – except that the result isn't text you have to take apart again.

```powershell
$words = 'apple', 'pear', 'apple', 'plum', 'pear', 'apple'

$words | Group-Object | Sort-Object -Property Count -Descending | Select-Object -Property Name, Count
```

```text
Name  Count
----  -----
apple     3
pear      2
plum      1
```

A hashtable goes through a pipeline too, once you ask for its entries with `GetEnumerator()`. Each entry has a `Key` and a `Value`, so the maximum search becomes a sort:

```powershell
$top = $pages.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1
Write-Output "$($top.Key) - $($top.Value)"      # Toth Eszter - 45
```

In your assignment

**Loop or pipeline?**

Both are correct. The loop with a hashtable is longer, but every step is visible and you can print the variables while you debug. The pipeline is short and hard to get half right. Write the version you can explain line by line – and when a result looks wrong, print the hashtable (`$counts` on a line of its own) before you touch the logic.

## Practice

Fourteen tasks in four groups, and a larger one to finish. Each has an expected result to check yourself against, and a hint with the approach and the lines that matter – the script is yours to write, so try before you open it.

- check the parameters, and that the input file or directory exists (`Test-Path`)
- error messages go through `Write-Error`, and the script stops with `exit 1`
- read text files with `Get-Content -Encoding UTF8`, `.Trim()` every line and skip the empty ones
- full cmdlet names, no aliases

### Files and directories

Task 1

**A directory for every identifier**

[neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/neptun_codes.txt) holds one student identifier per line. For each of them create a directory of the same name – if it doesn't exist yet – inside `users`, and copy a welcome file into each one. The identifier file and the file to copy ([read.me](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/read.me)) are parameters; an optional third one replaces `users`. An identifier is valid if it is exactly six letters or digits; warn about the others and skip them. The file has CRLF line endings, an empty line, spaces around one code and no final newline.

```text
PS> ./create_user_dirs.ps1 neptun_codes.txt read.me
Created: users/RVR55V
Created: users/ASD32B
Created: users/abc123
Created: users/GFGDDD
Created: users/AS55VF
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
Created: users/X1Y2Z3
6 directory(ies) created, 0 already existed, 2 invalid identifier(s)
```

Run it a second time: every directory is now reported as `Already exists`, and the file is copied again anyway. On Windows the paths print as `users\RVR55V`.

**Approach:**

Trim, skip empty lines, validate with an anchored pattern and `{6}`. Then the create-if-missing lines from the section on directories. Three counters give the summary line.

```powershell
if ($id -notmatch '^[A-Za-z0-9]{6}$') {       # exactly six letters or digits
    Write-Warning "Invalid identifier skipped: '$id'"
    $invalid++
    continue
}

$userDir = Join-Path -Path $TargetDir -ChildPath $id
# ... create $userDir only if Test-Path -PathType Container says it is missing ...
Copy-Item -Path $ReadmeFile -Destination $userDir     # on every run: an older copy is overwritten
```

Task 2

**Random passwords**

For every valid identifier in [neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/neptun_codes.txt), generate a random password and write `identifier password` lines to an output file. The length is an optional third parameter, 12 by default. Your passwords will differ from these – and from run to run.

```text
PS> ./generate_passwords.ps1 neptun_codes.txt passwords.txt
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
6 password(s) of length 12 written to 'passwords.txt'
PS> Get-Content passwords.txt
RVR55V 2i064gPPF1B8
ASD32B ZkZhOQfckbbL
abc123 svDqCYsu7jkt
GFGDDD rfKwS1otaZ9Z
AS55VF nZXvNNIJPoDg
X1Y2Z3 28G19NiET0Hi
```

**Approach:**

A function builds one password; the main loop is the validation of task 1 plus one call. Collect the output lines in an array and write them with a single `Set-Content` at the end.

```powershell
function New-Password {
    param([int]$Length)

    $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    $password = ""
    for ($i = 0; $i -lt $Length; $i++) {
        $password += $characters[(Get-Random -Maximum $characters.Length)]
    }
    return $password
}
```

A pipeline can do it in one line: `Get-Random -Count` picks that many of the items it receives, and `-join` glues them together. Each character can then occur at most once.

```powershell
-join ($characters.ToCharArray() | Get-Random -Count $Length)
```

Task 3

**Sort files into directories by extension**

The script takes a directory and a list of extensions. For every listed extension it creates a subdirectory of that name and moves the matching files into it. Everything else stays where it is. Work on a copy of `mixed_files`.

```text
PS> Copy-Item mixed_files test_dir -Recurse
PS> ./sort_by_extension.ps1 test_dir txt,csv
Moved: archive.TXT -> txt/
Moved: data.csv -> csv/
Moved: notes.txt -> txt/
Moved: prices.csv -> csv/
Moved: todo.txt -> txt/
5 file(s) moved
```

`archive.TXT` goes to `txt` despite its capitals; `app.log`, `big.log` and `README`, which has no extension, stay.

**Approach:**

Loop over `Get-ChildItem -File`. Normalise the extension – drop the dot, lowercase – and test it against the array. Move by `FullName`, so the script works from any directory.

```powershell
$extension = $file.Extension.TrimStart('.').ToLower()     # ".TXT" → "txt", README → ""

if ($Extensions -notcontains $extension) {
    continue                                              # not asked for, or no extension: the file stays
}

$targetDir = Join-Path -Path $Directory -ChildPath $extension
# ... create $targetDir if it is missing ...
Move-Item -Path $file.FullName -Destination $targetDir
```

Task 4

**Files modified in the last n days**

The script takes a number of days and, optionally, a directory (default: the current one), and lists the files modified within that many days, with the time of the modification. Files you have just downloaded are all new, so first make one of them older by assigning to its `LastWriteTime`:

```text
PS> (Get-Item mixed_files/todo.txt).LastWriteTime = (Get-Date).AddDays(-10)
PS> ./modified_within_days.ps1 7 mixed_files
2026-11-11 14:45  app.log
2026-11-11 14:45  archive.TXT
2026-11-11 14:45  big.log
2026-11-11 14:45  data.csv
2026-11-11 14:45  notes.txt
2026-11-11 14:45  prices.csv
2026-11-11 14:45  README
7 file(s) modified in the last 7 day(s)
```

Your dates will be today's; the point is that `todo.txt` is missing from the list.

**Approach:**

Compute the limit once, before the loop. The check for a missing first parameter is the `ContainsKey` one – `0` days is a valid request.

```powershell
$limit = (Get-Date).AddDays(-$Days)

foreach ($file in Get-ChildItem -Path $Directory -File) {
    if ($file.LastWriteTime -ge $limit) {
        $modified = $file.LastWriteTime.ToString('yyyy-MM-dd HH:mm')
        Write-Output "$modified  $($file.Name)"
    }
}
```

Task 5

**Files larger than a given size**

The script takes a size in bytes and, optionally, a directory, and lists the files larger than that, largest first, then how many there were.

```text
PS> ./larger_than.ps1 50 mixed_files
3360 bytes  big.log
84 bytes  app.log
60 bytes  notes.txt
3 file(s) larger than 50 bytes
PS> ./larger_than.ps1 1KB mixed_files
3360 bytes  big.log
1 file(s) larger than 1024 bytes
```

**Approach:**

The core is the pipeline of Fig. 2, stored in a variable with `@( )` so `.Count` is right for one file and for none. Because the parameter is a number type, the caller can write `1KB`.

```powershell
param(
    [long]$MinSize,                   # [long]: a 64-bit integer; 1KB on the command line arrives as 1024
    [string]$Directory = "."
)

$largeFiles = @(Get-ChildItem -Path $Directory -File |
    Where-Object { $_.Length -gt $MinSize } |
    Sort-Object -Property Length -Descending)
```

### Parameters and switches

Task 6

**Tidy a list, optionally in place**

The script takes a file of identifiers such as [neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/neptun_codes.txt) and produces the tidy version of the list: only the valid codes, in uppercase, in alphabetical order. By default it prints the result and leaves the file alone; with the `-InPlace` switch it writes the result back into the file itself. Try the switch on a copy.

```text
PS> ./tidy_codes.ps1 neptun_codes.txt
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
ABC123
AS55VF
ASD32B
GFGDDD
RVR55V
X1Y2Z3
PS> Copy-Item neptun_codes.txt test.txt
PS> ./tidy_codes.ps1 test.txt -InPlace
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
6 identifier(s) written back to 'test.txt'
```

**Approach:**

Validate as in task 1, collect the uppercased codes in an array, sort it, and let the switch decide where it goes. The switch must work in any position: `./tidy_codes.ps1 -InPlace test.txt`.

```powershell
$codes += $code.ToUpper()                 # inside the loop, after the validation
…
$codes = @($codes | Sort-Object)          # @( ): still an array when a single code is left

if ($InPlace) {
    Set-Content -Path $File -Value $codes -Encoding UTF8
    Write-Output "$($codes.Count) identifier(s) written back to '$File'"
} else {
    Write-Output $codes
}
```

**As a pipeline:**

Reading and writing the same file in one pipeline works only if the reading is finished before the writing starts – that is what the parentheses around `Get-Content` are for. Without them the file is opened for writing while it is still being read.

```powershell
(Get-Content $File) |                                  # the ( ) are required: read everything first
    ForEach-Object { $_.Trim().ToUpper() } |
    Where-Object { $_ -match '^[A-Z0-9]{6}$' } |
    Sort-Object |
    Set-Content $File
```

Task 7

**A times table with a default**

The script prints the times table of the number it gets, up to 10 – or up to the value of an optional second parameter, `-UpTo`. Without a number it prints a usage message. Make sure `./times_table.ps1 0` works: zero is a number too.

```text
PS> ./times_table.ps1 7 -UpTo 3
7 x 1 = 7
7 x 2 = 14
7 x 3 = 21
PS> ./times_table.ps1
Write-Error: Usage: times_table.ps1 <number> [up to]
```

**Approach:**

Two `[int]` parameters, the second with a default. `if (-not $Number)` would reject the zero – ask `$PSBoundParameters` instead.

```powershell
param(
    [int]$Number,
    [int]$UpTo = 10
)

if (-not $PSBoundParameters.ContainsKey('Number')) { … }     # 0 is a number too: ./times_table.ps1 0 must work

foreach ($i in 1..$UpTo) {
    Write-Output "$Number x $i = $($Number * $i)"
}
```

### Hashtables and grouping

Task 8

**Who printed the most?**

Lines of [print_log.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/print_log.txt) are `Name, pages, printer`. A name can appear in **several** lines, and its pages add up. Print who printed the most, and how many pages. The file has CRLF endings, an empty line, and its last line has neither spaces after the commas nor a newline.

```text
PS> ./most_pages.ps1 print_log.txt
Toth Eszter - 45
```

**Approach:**

The two loops of the hashtable section: sum per name (Fig. 4), then the maximum search. `.Trim()` removes the spaces around a field and keeps the one inside the name. The count is still text after the split: convert it, or `+` glues strings – `" 12" + 1` is `" 121"`.

**Going further:**

Change the key and the same script answers another question – which printer is the busiest? Expected: `library - 55`.

```powershell
$printer = "$printer".Trim()
$pages[$printer] = $pages[$printer] + [int]$count      # library: 55, lab-2: 38, lab-1: 13
```

Task 9

**The most common illness per month**

Each line of [patients_messy.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/patients_messy.txt) is `Name, YYYY-MM-DD, illness`, and the file is dirty in every way: a header line, spaces around the commas, punctuation after the illness (`fejfájás.`, `fejfájás!`, `megfázás,`), mixed case (`Fejfájás`), an empty and a broken line, CRLF endings, no final newline. For every month print the most common illness and its number of patients; warn about invalid lines. (The illnesses are Hungarian: *fejfájás* headache, *megfázás* cold, *lábfájás* leg pain, *gyomorpanaszok* stomach complaints.)

```text
PS> ./most_common_illness_per_month.ps1 patients_messy.txt
WARNING: Invalid line skipped: 'Név, Dátum, Betegség'
WARNING: Invalid line skipped: 'Hibás Sor'
Month 05: lábfájás (3 patient(s))
Month 09: gyomorpanaszok (2 patient(s))
Month 10: fejfájás (3 patient(s))
Month 11: megfázás (3 patient(s))
```

**Step 1: clean and count:**

Lowercase the illness with `.ToLower()` – it handles accented letters – and delete everything that isn't a letter: `\p{L}` means "any letter", `[^…]` negates it. The header and the broken line fail the date check, so they need no special case. Count under a composite key.

```powershell
$name, $date, $illness = $line -split ','

$date = "$date".Trim()                                      # "$date": a missing field is $null, and $null has no Trim()
$illness = "$illness".ToLower() -replace '[^\p{L}]', ''     # "  Fejfájás." → "fejfájás"

if ($date -notmatch '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' -or $illness -eq '') {
    Write-Warning "Invalid line skipped: '$line'"
    continue
}

$month = $date.Substring(5, 2)                              # characters 5 and 6 of 2023-10-13: "10"
$key = "${month}:$illness"
$counts[$key] = $counts[$key] + 1
```

**Steps 2 and 3: the maximum per month, sorted:**

Two more hashtables, both keyed by the month (Fig. 5). You only read `$counts` while looping over it, so no copy of the keys is needed.

```powershell
foreach ($key in $counts.Keys) {
    $month, $illness = $key -split ':'

    if ($counts[$key] -gt $maxCount[$month]) {    # a month not seen yet is $null – any count beats it
        $maxCount[$month] = $counts[$key]
        $maxIllness[$month] = $illness
    }
}

foreach ($month in ($maxCount.Keys | Sort-Object)) {
    Write-Output "Month ${month}: $($maxIllness[$month]) ($($maxCount[$month]) patient(s))"
}
```

**Pipeline version:**

Turn every cleaned line into an object of your own with a `Month` and an `Illness` property, and let `Group-Object` count – once by month, then by illness within each group. This is where objects pay off.

```powershell
$records = foreach ($line in Get-Content -Path $File -Encoding UTF8) {
    # ... clean and validate as before ...
    [pscustomobject]@{ Month = $month; Illness = $illness }
}

$records | Group-Object -Property Month | Sort-Object -Property Name | ForEach-Object {
    $top = $_.Group | Group-Object -Property Illness |
        Sort-Object -Property Count -Descending | Select-Object -First 1
    Write-Output "Month $($_.Name): $($top.Name) ($($top.Count) patient(s))"
}
```

The same data is cleaned and counted in bash in [the lesson on text processing](https://laszlonemes.com/university/computer-systems/text-processing) – compare how much of that script was cleaning.

Task 10

**Files and bytes per extension**

For a directory given as a parameter, print how many files each extension has and how many bytes they take together, ignoring the case of the extension. Files without an extension are reported as `(none)`.

```text
PS> ./per_extension.ps1 mixed_files
.csv: 2 file(s), 79 bytes
.log: 2 file(s), 3444 bytes
.txt: 3 file(s), 105 bytes
(none): 1 file(s), 28 bytes
```

**Approach:**

Two hashtables with the same key – one counts, one adds up `Length` – and a sorted loop over the keys of either.

```powershell
$extension = $file.Extension.ToLower()             # ".TXT" and ".txt" are one key
if ($extension -eq '') { $extension = '(none)' }

$count[$extension] = $count[$extension] + 1
$bytes[$extension] = $bytes[$extension] + $file.Length
```

**Pipeline version:**

`Group-Object` accepts a script block as the key. Each group still carries its file objects in `Group`, so they can be measured.

```powershell
Get-ChildItem -Path $Directory -File | Group-Object { $_.Extension.ToLower() } | ForEach-Object {
    $sum = ($_.Group | Measure-Object -Property Length -Sum).Sum      # $_.Group: the files of this group
    Write-Output "$($_.Name): $($_.Count) file(s), $sum bytes"
}
```

Task 11

**Patients per month**

From [patients_messy.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/patients_messy.txt), print how many patients each month had, sorted by month. Expected: `05: 4`, `09: 3`, `10: 5`, `11: 4`.

**Approach:**

One hashtable with the month as the key. The illness doesn't matter here, so only the date needs cleaning and checking – a simpler relative of task 9.

### Strings and numbers are objects too

Task 12

**Statistics of a number file**

[numbers_spaces.txt](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/numbers_spaces.txt) has one number per line – with spaces, a tab, empty lines, mixed line endings and two invalid lines. Print how many valid integers it has, the smallest, the largest and their average rounded to two decimals, and write the valid numbers in ascending order to `sorted_numbers.txt`. Warn about invalid lines with their line number.

```text
PS> ./number_stats.ps1 numbers_spaces.txt
WARNING: Line 6: 'abc' is not an integer, skipped
WARNING: Line 8: '1.5' is not an integer, skipped
Count: 6
Smallest: -4
Largest: 100
Average: 22.17
PS> Get-Content sorted_numbers.txt
-4
3
7
12
15
100
```

**Approach:**

Trim, skip empty lines, validate with `'^-?[0-9]+$'`, and collect the values in an array *as numbers*. `Measure-Object` then does all the arithmetic in one call.

```powershell
$numbers += [int]$line                 # collect numbers, not strings: as text, "100" sorts before "9"
…
$stats = $numbers | Measure-Object -Minimum -Maximum -Average

$stats.Count                           # 6
[math]::Round($stats.Average, 2)       # 22.17

Set-Content -Path sorted_numbers.txt -Value ($numbers | Sort-Object) -Encoding UTF8
```

Task 13

**The longest line**

Print the longest line of [read.me](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/read.me) and its length. Solve it with a loop (a maximum search) and with a pipeline. Expected: 47 characters, `Please read the rules before you start working:`.

**Approach:**

A line is a string, and a string has a `Length` just as a file does. So the pipeline is the one that found the largest file:

```powershell
Get-Content -Path $File -Encoding UTF8 |
    Sort-Object -Property Length -Descending |
    Select-Object -First 1
```

Task 14

**Slow requests in a log**

Lines of [mixed_files/big.log](https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/mixed_files/big.log) look like `… request 051 processed in 97 ms`. Print how many requests took longer than 80 ms, and which one was the slowest. Expected: 11 requests; the slowest is `051` with 97 ms.

**Approach:**

After a successful `-match`, the automatic variable `$Matches` holds what each `( )` group of the pattern matched. The groups are text; convert before comparing. Then a counter and a maximum search.

```powershell
if ($line -match 'request ([0-9]+) processed in ([0-9]+) ms') {
    $request = $Matches[1]           # the first ( ) group: 051
    $ms = [int]$Matches[2]           # the second one, as a number: 97
}
```

### A larger one

Task 15

**Port a bash script**

Take a bash script you have written – for example the grades report from [the lesson on reading and writing files](https://laszlonemes.com/university/computer-systems/files-line-endings), or the patient queries with options from [shell scripting in practice](https://laszlonemes.com/university/computer-systems/shell-practice) – and write it in PowerShell.

**Approach:**

Don't translate line by line; decide first what the script does with its input. Options become `[switch]` or named parameters, `case` becomes `switch`, parallel arrays or `declare -A` become a hashtable – and count the lines you no longer need: the guard for a last line without a newline, the `\r` stripping, the temporary file.

## Common mistakes

| Wrong | Right | Why |
| --- | --- | --- |
| `ls \| Where-Object { $_.Length -gt 1KB }` | `Get-ChildItem \| Where-Object { $_.Length -gt 1KB }` | On Linux and macOS `ls` returns text; `Length` is the length of the name. |
| `"Size: $file.Length"` | `"Size: $($file.Length)"` | A property inside a string needs `$( )`. |
| `$file.Extension -eq 'txt'` | `$file.Extension -eq '.txt'` | The extension includes the dot. |
| `Get-ChildItem $dir` for "the files" | `Get-ChildItem $dir -File` | Without -File the subdirectories come too. |
| `"$TargetDir\$id"` | `Join-Path -Path $TargetDir -ChildPath $id` | Outside Windows, programs don't treat `\` as a separator. |
| `New-Item …` printing the new directory | `New-Item … \| Out-Null` | Whatever a command returns is part of the script's output. |
| `Remove-Item $Directory -Recurse`, untested | first with `-WhatIf` | It only prints what it would delete; the real delete is permanent. |
| `./script.ps1 test_dir txt csv` | `./script.ps1 test_dir txt,csv` | Array elements are separated by commas; a space starts another argument. |
| `if ($Days)` for "was it given?" | `$PSBoundParameters.ContainsKey('Days')` | `0` is false, but it can be a valid value. |
| a parameter or key named `Count` | another name (`$Times`, `$Total`) | In a hashtable – and in `$PSBoundParameters` – the key `Count` hides the `.Count` property. |
| `$count + 1` after `-split` | `[int]$count + 1` | The field is text: `" 12" + 1` is `" 121"`. |
| sorting numbers that are still strings | convert first: `[int]$line` | As text, `100` sorts before `9`. |
| `$date.Trim()` on a short line | `"$date".Trim()` | A missing field is `$null`, and `$null` has no methods. |
| `foreach ($key in $counts.Keys) { $counts[$key] = 0 }` | `foreach ($key in @($counts.Keys)) { … }` | A hashtable can't change while it is being walked; `@( )` copies the keys. |
| printing `$counts.Keys` as they come | `$counts.Keys \| Sort-Object` | The order of the keys is not defined. |
| `Get-Content $f \| … \| Set-Content $f` | `(Get-Content $f) \| … \| Set-Content $f` | Without the parentheses the file is written while it is still being read. |
| `… > out.txt` in a script | `… \| Set-Content -Path out.txt -Encoding UTF8` | In Windows PowerShell 5.1, `>` writes UTF-16. |

## Summary

- **file object** – what `Get-ChildItem` and `Get-Item` return: `Name`, `Extension`, `Length`, `LastWriteTime`, `FullName`
- **date object** – a point in time with methods (`AddDays`, `ToString`); compares with `-ge`, `-lt`
- **switch** – a `[switch]` parameter: no value, true when present
- **hashtable** – `@{}` – one value per key; a missing key reads as `$null`
- **composite key** – two fields glued into one key with a separator: `10:fejfájás`
- **Group-Object** – groups equal items and counts them: `Name`, `Count`, `Group`

- Ask the object: `$file.Length`, `$file.Extension`, `$file.LastWriteTime` – and always `Get-ChildItem -File` when you mean files.
- Dates compare like numbers; `(Get-Date).AddDays(-7)` is "a week ago". Sizes take `KB`, `MB`, `GB`.
- Create only what is missing (`Test-Path`), build paths with `Join-Path`, silence `New-Item` with `Out-Null`, rehearse deletes with `-WhatIf`.
- `ls`, `cat`, `cp`, `mv`, `rm` and `sort` are aliases only on Windows – full cmdlet names in scripts.
- Options are `[switch]` parameters, optional values have defaults, and `$PSBoundParameters.ContainsKey()` tells you what was really passed.
- Per-key questions: clean the key, `$h[$k] = $h[$k] + n`, then a maximum search or a sorted loop over the keys.
- Never assign to a hashtable inside a loop over its own keys – loop over `@($h.Keys)`.

## Further reading

- [about_Hash_Tables](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_hash_tables) – everything a hashtable can do, including ordered ones (`[ordered]@{}`).
- [about_Functions_Advanced_Parameters](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_functions_advanced_parameters) – parameter types, defaults, switches and validation attributes.
- [about_Regular_Expressions](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_regular_expressions) – the regex dialect of `-match` and `-replace`, `\p{L}` included.
- [Get-ChildItem](https://learn.microsoft.com/powershell/module/microsoft.powershell.management/get-childitem) – all its parameters; the same page from the terminal: `Get-Help Get-ChildItem -Examples`.
- `Get-ChildItem | Get-Member` lists every property and method of a file object, right in the terminal.
- [regex101](https://regex101.com/) – try a pattern and have each piece explained; choose the ".NET (C#)" flavour for PowerShell.
