How to remove a large number of unused keywords from Apple Photos app? My Photo library has evolved over many years, starting out as an iPhoto library, then being merged with an Aperture library, then becoming a Photos library.  Over the course of the last 20 or so years I've accumulated thousands of keywords that have zero photos associated with them.

The Photos app itself does not support a 'Delete unused keywords' feature, and I have looked at using AppleScript to do this, but, despite being quite an experienced programmer, I could not work out how to do this (AppleScript drives me nuts quite honestly).

I'm hoping someone has already written such a script, or if not a script then some other utility that will do this for me.
Below is a script I wrote and tested over quite a few days.  Whilst Apple's Photos.app is scriptable, you've already observed that it lacks the necessary methods to delete unused keywords.  If you are familiar with AppleScript and the notion of UI scripting, this appears to be the only option available.

Note: For UI scripting to work, you need to provide the necessary accessibility privileges to Script Editor.

My personal view towards UI scripting is generally a negative one, but I have taken extra care to try and mitigate the typical temperamental nature and fragility of UI scripts, and done a few tests on my system to observe a reasonably smooth operation.

However, the one specific feature I couldn't (wasn't willing to) check during testing is how the script performs when there are thousands of keywords, and/or an extremely large Photos library.  I myself have a Photos library consisting of fewer than 100 photos, and none of them had been tagged with keywords, so I created a sample of 20, of which I randomly assigned about half of them.

In theory, the only affect library volume or keyword count should have on script operation is the length of time it takes to execute.  But, with AppleScript, there may be timeout issues that prematurely abort the script; and with UI scripting, the probability of it throwing an error tends to increase with running time.

Specific notes regarding these uncertainties on performance are expanded upon below the script.  If you run into any issues, please report back and I shall consider how to implement a fix.  There ought to be no adverse effects should the script not perform ideally (i.e. you won't lose any photos).  Suboptimal performance ought to only result in an incomplete purge of keywords.

#!/usr/bin/osascript
--------------------------------------------------------------------------------
# pnam: PHOTOS#DELETE UNUSED KEYWORDS
# nmxt: .applescript
# pDSC: A UI scripting-dependent script to remove keywords from Photos.app that
#       have not been assigned to any photos

# plst: -

# rslt: «list» : On successful completion, the script reacquires an updated
#                list of disused keywords and returns the result (hopefully
#                an empty list)
#       «err » : Script failure throws an error.  Running the script again
#                with Photos.app already open may yield a different result.
--------------------------------------------------------------------------------
# sown: CK
# ascd: 2019-01-07
# asmo: 2019-01-07
# vers: 1.0
--------------------------------------------------------------------------------
use sys : application "System Events"
use Photos : application "Photos"

property process : a reference to application process "Photos"

property _M : a reference to every media item
--------------------------------------------------------------------------------
# IMPLEMENTATION:
activate Photos
open the keywordManager

set everyKeyword to the list of allKeywords()
set activeKeywords to the list of currentKeywords()
set disusedKeywords to difference(everyKeyword, activeKeywords)

tell the keywordManager
    tell its keywordEditor
        open it
        select disusedKeywords
        delete
        close
    end tell
end tell

set everyKeyword to the list of allKeywords()
set activeKeywords to the list of currentKeywords()

close the keywordManager

set disusedKeywords to difference(everyKeyword, activeKeywords)
--------------------------------------------------------------------------------
# HANDLERS &amp; SCRIPT OBJECTS:
script keywordManager
    property window : a reference to window "Keywords" of my process
    property scroll area : a reference to scroll area 2 of my window
    property button : a reference to button "Edit Keywords" of my window
    property menu item : a reference to ¬
        (menu item "Keyword Manager" of ¬
            menu 1 of ¬
            menu bar item "Window" of ¬
            menu bar 1 of my process)

    script keywordEditor
        property title : "Manage My Keywords"
        property window : a reference to window title of my process
        property scroll area : a reference to scroll area 1 ¬
            of my window
        property table : a reference to table 1 of my scroll area
        property group : a reference to group 1 of my window
        property button : a reference to (first button of my group ¬
            whose accessibility description = "remove")
        property menu item : a reference to ¬
            (menu item "Select All" of ¬
                menu 1 of ¬
                menu bar item "Edit" of ¬
                menu bar 1 of my process)
        to open
            if my window exists then return
            tell the keywordManager to if not ¬
                (its window exists) then ¬
                open it

            click the keywordManager's button
            with timeout of 10 seconds
                repeat until the my window exists
                    delay 0.5
                end repeat
            end timeout
            perform action "AXRaise" of my window
        end open

        to close
            if not (my window exists) then return
            click button "OK" of my window
        end close

        to select |keywords| as list
            local |keywords|

            set focused of my table to true

            click my menu item

            script deselect
                property list : |keywords|
                on fn(x)
                    if the value of x's text field 1 ¬
                        is not in my list then
                        set x's selected to false
                        return true
                    end if
                    false
                end fn
            end script

            filterItems from rows of my table ¬
                given handler:deselect
        end select

        to delete
            if not (my button exists) then return 0
            click my button
        end delete
    end script

    on menuItem()
        tell my menu item to if exists then return it
        false
    end menuItem

    to open
        if my window exists then return false
        tell the keywordEditor to if ¬
            (its window exists) then ¬
            return close it

        click my menuItem()

        # tell sys to keystroke "k" using command down

        with timeout of 10 seconds
            repeat until my window exists
                delay 0.5
                set my process's frontmost to true
            end repeat
        end timeout
        perform action "AXRaise" of my window
    end open

    to close
        if not (my window exists) then return
        click (value of attribute "AXCloseButton" of my window)
    end close
end script

on allKeywords()
    script |keywords|
        property list : accessibility description of ¬
            every checkbox of the keywordManager's scroll area ¬
            whose role description = "keyword checkbox"
    end script
end allKeywords

on currentKeywords()
    script
        property keep : keywords of _M
        property list : strings in unique_(flatten_(keep))
    end script
end currentKeywords

on __(function)
    if the function's class = script ¬
        then return the function

    script
        property fn : function
    end script
end __

to filterItems from L as list into R as list : missing value ¬
    given handler:function
    local L, R

    if R = missing value then set R to {}

    script
        property list : L
        property result : R
    end script

    tell the result to repeat with x in its list
        if __(function)'s fn(x, its list, its result) ¬
            then set end of its result to x's contents
    end repeat

    R
end filterItems

to foldItems from L at |ξ| : 0 given handler:function
    local L, |ξ|, function

    script
        property list : L
    end script

    tell the result to repeat with i from 1 to length of its list
        set x to item i in its list
        tell __(function)'s fn(x, |ξ|, i, L) to ¬
            if it = missing value then
                exit repeat
            else
                set |ξ| to it
            end if
    end repeat

    |ξ|
end foldItems

on difference(A as list, B as list)
    local A, B

    script
        on notMember(M)
            script
                on fn(x)
                    x is not in M
                end fn
            end script
        end notMember
    end script

    filterItems from A given handler:result's notMember(B)
end difference

on union(A as list, B as list)
    local A, B

    script
        on insert(x, L)
            set end of L to x
            L
        end insert
    end script

    foldItems from A at B given handler:result's insert
end union

to flatten:L
    foldItems from L at {} given handler:union
end flatten:

on unique:L
    local L

    script
        on notMember(x, i, L)
            x is not in L
        end notMember
    end script

    filterItems from L given handler:result's notMember
end unique:
---------------------------------------------------------------------------❮END❯


Uncertainties on Threats to Performance


Regarding the volume of photos in your library, the line I am most mindful about is this one:

property _M : a reference to every media item


the effect of which will come into play at the points in the script where the property is dereferenced, i.e.

set activeKeywords to the list of currentKeywords()


The function of this line is to retrieve a list of all keywords currently assigned to at least one photo.  In order to do this, every single photo in your library needs to be enumerated (retrieved), and its keywords property evaluated.  This happens virtually immediately at the start of the script; and again after purging keywords to determine if the purge was complete.  It's a time-consuming process, and therefore a potential timeout threat for the script.

One ought to be able to extend the default timeout value like so:
    with timeout of 600 seconds
        set activeKeywords to the list of currentKeywords()
    end timeout

or it may be necessary to slightly alter the syntax in retrieving the photos so that the script directly targets the Photos app at the point of enumeration, instead of by way of property references; and then to enclose the Photos command within a timeout block.  But, for now, I've left it to see if the script will run on your system using the default timeout, which might not be a constraint if the enumeration takes place synchronously (and I don't know whether or not it does).
Regarding UI scripting potential roadblocks: the Photos AppleScript dictionary doesn't provide a way to retrieve all keywords that exist in the app.  The way the script works around this is to open up the Keyword Manager and read off the name of every keyword label that it detects under the section "Keywords".  What I don't know is whether every UI element containing a keyword label is loaded when the Keyword Manager window gets created; or whether they get loaded piecewise when a user scrolls through the list.  The latter situation would be irksome, as it would result in an incomplete list of keywords and then an incomplete purge.

One obvious solution would be to run the script multiple times to perform multiple purges until the are no remaining purgeable items.
When considering the worst-case scenario, analysing the script appears to have one of three possible outcomes (regardless of how the script terminates, be it through completion of its run or by throwing an error):


Either the script does nothing (nil result);
OR an incomplete purge occurs (partial success);
OR a complete purge occurs (success).


There does not seem to be a way for the script to fail in a way that negatively impacts the Photos library, so the worst-case scenario appears to be the nil result.  However, if we assume I can be wrong, you may want to put a margin on error on the potential worse-case scenario.

This margin is up to you and your judgment, which is tough to make when you perhaps don't know what types of things I'm typically wrong about.  If it helps, I would say label it impossible for the script to delete any of your photos, because it does not perform any filesystem operations.  If impossible is not good enough, then the clear precaution to take is to backup your entire Photos library beforehand.  Depending on the size of your library, this may range from simple to pain-staking for the sake of a near zero-chance event.

What the script does do (obviously) is read and edit lists of keywords.  So, while it ought not to be possible, it wouldn't be foolish to consider that all of your keywords for all of your photos might just disappear.  Should you want to cover yourself for this unlikely event, then I provide this "scriptlet" that you can run beforehand in order to back up your keywords:

property path : "~/Desktop/Photos.Keywords.Backup.plist"


backupKeywordsToFile at path
--! CAUTION: Uncommenting the line below
--! WILL OVERWRITE ALL KEYWORDS FOR ALL PHOTOS 
-- restoreKeywordsFromFile at path
--------------------------------------------------------------------------------
# HANDLERS &amp; SCRIPT OBJECTS:
use framework "Foundation"

property this : a reference to current application
property _0 : a reference to missing value
property _1 : a reference to reference

property NSDictionary : a reference to NSDictionary of this
property NSString : a reference to NSString of this
property NSURL : a reference to NSURL of this

to backupKeywordsToFile at fp as text
    local fp

    set fURL to NSURL's fileURLWithPath:((NSString's ¬
        stringWithString:fp)'s ¬
        stringByStandardizingPath())

    script
        use application "Photos"
        property _M : a reference to media items
        property properties : [keywords, id] of _M
        property keys : item 1 of my properties
        property refs : item 2 of my properties
    end script

    tell the result
        repeat with i from 1 to length of its keys
            if (item i of its keys) = missing value ¬
                then set item i of its keys to {}
        end repeat

        tell (NSDictionary's dictionaryWithObjects:(its keys) ¬
            forKeys:(its refs)) to set [success, E] ¬
            to its writeToURL:fURL |error|:_1
    end tell

    if E ≠ missing value then return E's localizedDescription() as text

    success
end backupKeywordsToFile

to restoreKeywordsFromFile at fp as text
    local fp

    set fURL to NSURL's fileURLWithPath:((NSString's ¬
        stringWithString:fp)'s ¬
        stringByStandardizingPath())

    script
        property result : NSDictionary's ¬
            dictionaryWithContentsOfURL:fURL ¬
                |error|:_1
        property mediakeys : item 1 of my result
        property E : item 2 of my result
        property keys : null
        property refs : null
    end script

    tell the result
        if its E ≠ missing value then return its E's ¬
            localizedDescription() as text

        set its keys to its mediakeys's allObjects() as list
        set its refs to its mediakeys's allKeys() as list

        repeat with i from 1 to length of its refs
            set x to item i of its refs
            set keys to item i of its keys

            tell application "Photos" to set ¬
                keywords of media item id x ¬
                to keys
        end repeat
    end tell
end restoreKeywordsFromFile


In backing up your keywords, the script will have to enumerate the entire Photos library.  Therefore, regardless of whether or not you need the backup, running this script first will give you an indication of how slowly/quickly your library can be read.