Can I import data from CSV into SwiftData?

I’m making a table of tunes with several columns (so far just using strings but will eventually include audio players and links to images).

At this point I just have a simple UI to enter field data into SwiftData and sort it by either tune name or key. I’ll add the other columns and functionality later.

I currently have an excel file with over 200 rows. Is there a way to import that data into my SwiftData db so I don’t have to type in all that data as individual items?

@BarbE

Hi Barb,

Welcome to the community.

Yes you can, but it’s a bit of work.

As a suggested first step, you need to export the data from Excel as a CSV file and configure the export to separate each row of data with a comma as a delimiter between each column. You can then drag the file into your Xcode File Navigator panel on the left so that is part of the Bundle. Let’s say you called the file “data.csv”

You can then open that file and import the entire file as a String which you can then separate into an array of rows by using the “\n” line terminator character as the delimiter which should give an array with the more than 200 rows you referred to above. Are you still with me?

You then loop through the array of rows and split each row into an array of columns delimited by the comma character which you specified when exporting the data from Excel. Each element of the array of columns will be a String. Have I lost you yet?

Since you now have the row spit into an array of columns, you can create an instance of your SwiftData model and then reference each element of the array of columns and match that to the relevant SwiftData attribute.

Does that make sense?

If you can provide me a sample of your data I can write some code to do what you want it to do.

Just got back to my machine… I think I get it and want to give it a try on my own, but if I get stuck I’ll holler back. Thank you SO much!

OK, I’m confused. Lots I don’t know here. Let’s start here:

When I add the csv to the nav, does it go here? (I’ve named it TestTuneCsv)
image

How do I “import the entire file as a string”?

Here’s some data… some fields are empty which explains all the commas:

Key,Tune,Notes,Mp3,Chart,Source,Status\n

C,Acrobat’s New Trick,Frank Blade,Love\n

D,Across the Sea,No G - long pause,Isham Monday,\n

C,Altamont,John Lusk,\n

A,Ball and Chain Hornpipe,Kenny Baker,Meh\n

When you create a csv version of your Excel data file is should save that with a file extension of .csv. It could be that your Mac is configured to not show the file extensions (that’s the default) on any file but it’s there even if you can’t see it.

The extension identifies the file type so that the operating system knows how to deal with them.

To see it, in Finder, select the file and then right click and select “Get Info”. You should see the

To change the settings on your Mac so that all file extensions are visible, see this link.

Take note of the warning about changing a file extension name because it could mean that the file is no longer readable.

Text files have a extension of .txt
JPG images have an extension of .jpg
In Xcode your ContentView has a file extension of .swift

I made an assumption that you were aware that a text file has an invisible carriage return or line terminator for each line of text. It’s there but you can’t see it so there is no need to add it to each line.

There appears to be some inconsistencies with the sample data in that if there are any columns that have no data then your csv file should show a line where there are two or more commas in succession.

In other words there should be exactly the same number of separators for each row irrespective if there is or is not data in a specific column.

Lets say you had a transaction file downloaded from your bank where you had this kind of layout:

Date,Debit,Credit,Balance,Description
11/8/2008,,1753.98,2875.77,PAYMENT THANKYOU
5/8/2008,115,,4629.75,GROCERIES

In the case of a credit transaction there would be two commas just prior as in the first transaction and in the case of a debit there would be two commas just after.

To process that csv file I created a function to do so and created a dataModel to mirror the transaction structure.

struct Transaction: Identifiable {
    var id = UUID()
    var date: String
    var debit: Double
    var credit: Double
    var balance: Double
    var description: String
}

My ViewModel where the data is processed:

@Observable
class ViewModel {
    var transactions = [Transaction]()

    func getTransactions() {
        let url = Bundle.main.url(forResource: "anz", withExtension: "csv")
        if let url {
            do {
                let data = try String(contentsOf: url)
                let allRecords = Array(data.components(separatedBy: "\r\n")) // \r\n means carriage return and line feed which I needed to use in this case
                var records = Array(allRecords.dropFirst()) // Remove the header record
                records = records.dropLast() // Removes the last record which is blank

                for record in records {
                    let columns = Array(record.components(separatedBy: ","))
                    let newTransaction = Transaction(date: columns[0], debit: Double(columns[1]) ?? 0.0, credit: Double(columns[2]) ?? 0.0, balance: Double(columns[3]) ?? 0.0, description: columns[4])
                    transactions.append(newTransaction)
                }
                print(transactions)

            } catch {
                print(error.localizedDescription)
            }

        }
    }
}

I hope that gives you some idea of how to process your own csv file.

How would you call this getTransactions() function from a view to display the items in a ForEach?

Hi @dano9258

In the example I made there’s s bit of setup.
I have a ViewModel which looks like this:

import Foundation

@Observable
class ViewModel {
    var transactions = [Transaction]()

    func getTransactions() {
        let url = Bundle.main.url(forResource: "anz", withExtension: "csv")
        if let url {
            do {
                let data = try String(contentsOf: url)
                let allRecords = Array(data.components(separatedBy: "\r\n")) // \r\n means carriage return and line feed which I needed to use in this case
                var records = Array(allRecords.dropFirst())  // Remove the header record using .dropFirst() (only if needed)
                records = records.dropLast() // Removes the last record which is blank

                for record in records {
                    let columns = Array(record.components(separatedBy: ","))
                    let newTransaction = Transaction(date: columns[0], debit: Double(columns[1]) ?? 0.0, credit: Double(columns[2]) ?? 0.0, balance: Double(columns[3]) ?? 0.0, description: columns[4])
                    transactions.append(newTransaction)
                }
//                print(transactions)
                for transaction in transactions {
                    print("\(transaction.date), \(transaction.credit), \(transaction.debit), \(transaction.balance), \(transaction.description)")
                }

            } catch {
                print(error.localizedDescription)
            }

        }
    }
}

In the program entry point I added a reference to that and injected that into the .environment() like this:

@main
struct ProcessCSVApp: App {
    @State var viewModel = ViewModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(viewModel)
        }
    }
}

Then in the View I did this to call it:

struct ContentView: View {
    @Environment(ViewModel.self) var viewModel

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
        .onAppear {
            viewModel.getTransactions()
        }
    }
}

In the above rather than Image and Text inside the VStack you would have something like

ForEach(model.transactions) { transaction in 
    Text(transaction.date)
    // etc etc 
}

Hope that helps.

That does help, thank you. I coded mine like this so far but it’s not reading the Boolean values correctly which is understandable since they are strings in the CSV file. Any idea how to use them as a Boolean in the app from the CSV file? Once I figure this out, then I can implement it all into Swiftdata and begin using the data.

import Foundation

struct Shop: Identifiable {
    let id = UUID()
    var name: String
    var address: String
    var instagram: String
    var website: String
    var phone: String
    var local: Bool
    var chain: Bool
    var roaster: String
    
}

func getShops(from CoffeeShops: String) -> [Shop] {
    var shops = [Shop]()
    
    // locate the csv file
    guard let url = Bundle.main.url(forResource: "CoffeeShops", withExtension: ".csv") 
    else {
        return []
    }
    
    // convert contents into one very long string
    
    var data = ""
    do {
        data = try String(contentsOf: url)
    } catch {
        print(error)
        return []
    }
        
    // split string into array of rows
    var rows = data.components(separatedBy: "\n")
    
    // remove first record
    let columnCount = rows.first?.components(separatedBy: ",").count
    rows.removeFirst()
    
    // split into columns
    for row in rows {
        
        let columns = row.components(separatedBy: ",")
        if columns.count == columnCount {
            let newShop = Shop(
                   name: String(columns[0]) as String,
                   address: String(columns[1]) as String,
                   instagram: String(columns[2]) as String,
                   website: String(columns[3]) as String,
                   phone: String(columns[4]) as String,
                   local: Bool(columns[5]) ?? true as Bool,
                   chain: Bool(columns[6]) ?? false as Bool,
                   roaster: String(columns[7]) as String
            )
            shops.append(newShop)
        }
    }
    return shops
}

My CSV file is like this (with local and chain as true/false):
Name,Address,Instagram,Website,Phone,Local,Chain,Roaster

Can you provide a sample of the data?

1Name,Address,Instagram,Website,Phone,Local,Chain,Roaster
2Coffee Java, 4573 N 7th Ave Phoenix AZ, none, 562-343-8499, true, false, West Stark Coffee

Hi @dano9258 ,

I added another record to your data just for the fun of it so the data file now looks like this:

1Name,Address,Instagram,Website,Phone,Local,Chain,Roaster
2Coffee Java, 4573 N 7th Ave Phoenix AZ, none, none, 562-343-8499, true, false, West Stark Coffee
3Window Coffee Bar,903 W Camelback Rd Phoenix AZ, https://www.instagram.com/windowcoffeebar/, https://www.windowcoffeebar.com, NoPhone, true, false, Unknown

I created a project here to get it working and this is what I did.

The Shop struct is exactly as you have.

The ViewModel:

import Foundation
import Observation

@Observable class ViewModel {

    var coffeeShops = [Shop]()

    func getShops() -> [Shop] {
        var shops = [Shop]()

        // locate the csv file
        guard let url = Bundle.main.url(forResource: "CoffeeShops", withExtension: "csv")
        else {
            return []
        }

        // convert contents into one very long string

        var data = ""
        do {
            data = try String(contentsOf: url)
        } catch {
            print(error)
            return []
        }

        // split string into array of rows
        var rows = data.components(separatedBy: "\n")

        // remove first record
        rows.removeFirst()
        // Remove last row as csv has a blank last record
        rows = rows.dropLast()

        // split into columns
        for row in rows {

            let columns = row.components(separatedBy: ",")

            let newShop = Shop(
                name: String(columns[0]) as String,
                address: String(columns[1]) as String,
                instagram: String(columns[2]) as String,
                website: String(columns[3]) as String,
                phone: String(columns[4]) as String,
                local: columns[5] == "true" ? true : false,
                chain: columns[6] == "true" ? true : false,
                roaster: String(columns[7]) as String
            )
            shops.append(newShop)

        }
        return shops
    }

}

ContentView:

import SwiftUI

struct ContentView: View {
    @Environment(ViewModel.self) var vm

    var body: some View {
        VStack {
            List {
                ForEach(vm.coffeeShops) { shop in
                    Text(shop.name)
                }
            }
            .listStyle(.plain)
        }
        .onAppear {
            vm.coffeeShops = vm.getShops()
        }

    }
}

#Preview {
    ContentView()
        .environment(ViewModel())
}

The App entry point:

import SwiftUI

@main
struct CoffeeShopsApp: App {
    @State var viewModel = ViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(viewModel)  //  ViewModel is injected into the environment 
        }
    }
}

Hope this all makes sense.

It makes sense overall. However, now it doesn’t load the CSV as no shops now show. At least before the three shops in the CSV file showed, it just wasn’t reading the boolean values of local and chain correctly.

Some quick questions regarding it now:

  1. How do I get it to actually read the CSV and create the list of shops since nothing is showing now?
  2. On the two below lines, is this loading into the model the values of local and chain as booleans or strings?
local: columns[5] == "true" ? true : false,
chain: columns[6] == "true" ? true : false,
  1. Any easy way then to merge this CSV file into SwiftData then on the onAppear, so that I can then add a favorite variable so when users click on the star button, it favorites it on their device only?

I’ve been following along on your Youtube videos for awhile now and just want to thank you as you guys have been so helpful and amazing!

Ah right, I didn’t set up my version to load the data into SwiftData. It just loads it into an array of the struct Shop. In my version I see the two records in the sample data I have. Perhaps if I share the entire example App I created you’ll see that it works.

Maybe if you shared your version with me via Dropbox or Google Drive I could make the necessary changes so that yours will work.

UPDATE:

  1. I actually figured out the code was working, the List {} was having issues displaying with the ZStack/Vstack system I have going on to display each one. So I got that working by just removing the List {}.

  2. I researched the lines of code and I think I understand it as this. It is reading the string in each variable, and if it is ‘true’ then it sets the boolean as true, otherwise it sets it as false? Is this correct? It’s displaying correctly now too!!

  3. Only thing left is to figure out how to now merge this CSV into Swiftdata so it’s each to create extra variables for the user on their own device such as favoriting it or marking as tried. Any ideas for this one?

Oh OK, great. Glad you got that sorted out.

Yes the code that tests for true or false is a ternary operator. If you haven’t seen them before it might be a little strange but effectively it’s an if then else test on one line. Look it up so that you understand how they work.

Loading the data in the SwiftData model should be straightforward. In the getData code, instead of loading it into the array of Shop you would load it directly into your SwiftData model.

1 Like

@dano9258

Just to clarify, Chris Ching is the owner of the YouTube channel, not me. I’m one of a number of helpers here to answer questions where I can and hopefully point people in the right direction.

Cheers
Chris Parker

Oh that’s my fault, all the same you guys are amazing!

I figured out how to load everything into swift data but it’s loading it every time I start the app, and doing it three times too. I have an on appear on my view which creates an array by calling the function to load the csv, returning the array and then a for each which then inserts them into the model context. I don’t know why it’s doing it every time and 3 at a time.

.onAppear {
let shopArray = getShops(from: “CoffeeShops”)
shopArray.forEach { shop in
modelContext.insert(shop)
}

You should delete all the records in the array first by looping through them and then using modelContext.delete(shop).

For example:

Assuming that you have a Query in place in your View which populates an array from SwiftData. For the sake of the exercise let’s call that array shopArray.

then

.onAppear {
    for shop in shopArray {
        modelContext.delete(shop)
    }

   let csvData = getShops(from: “CoffeeShops”)
   csvData.forEach { shop in
        modelContext.insert(shop)
   }
}

When you change the data in the SwiftData model, the query is triggered to refresh the array that you are using as the source for the List.

Let me know if that works.

That’s not going to work because I need the data loaded previously to stay put as the device user can then favorite or visit each Shop. If it deletes it each time the app loads, this won’t save. There has to be a way to make each entry into swift data unique and if the name or id already exists, then it skips creating it again?

OK that information would have been handy to know.

In that case you need to extract the data out of the csv file and then loop through that to see if it already exist in the SwiftData array. If it does then do not add it and then process the next record in the csv data otherwise add that record.